diff --git a/README.md b/README.md index 91b3b0e442254d454bc244e4eb34822dfbe9b5e8..e9756f1b54672a55248b17c079f76ae3b1c0a45d 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ language: --- # AbteeX SovereignCode - + Standalone release package for `AbteeXAILab/sovereigncode`. @@ -41,7 +41,10 @@ python -m sovereigncode.cli policy-matrix --capsule examples/capsule.restricted- python -m sovereigncode.cli tool-check --capsule examples/capsule.restricted-nz-code.json --request examples/request.allowed-local-edit.json --tool-name workspace_reader --action read_context python -m sovereigncode.cli opencode-config python -m sovereigncode.cli ui --smoke +python -m sovereigncode.cli serve --smoke +python -m sovereigncode.cli audit --limit 5 python -m sovereigncode.cli ui --port 8788 --open +python -m sovereigncode.cli serve --port 8788 --open ``` # AbteeX SovereignCode @@ -139,6 +142,26 @@ Run the browser operator console: py -3 -m tinyluminax.products.sovereigncode.cli ui --port 8788 --open ``` +Run the local policy API, persistent audit ledger, and browser console: + +```bash +py -3 -m tinyluminax.products.sovereigncode.cli serve --port 8788 --open +``` + +Smoke-check the service without opening a browser: + +```bash +py -3 -m tinyluminax.products.sovereigncode.cli serve --smoke +``` + +Read the audit ledger: + +```bash +py -3 -m tinyluminax.products.sovereigncode.cli audit --limit 10 +``` + +The service exposes `GET /health`, `GET /v1/audit`, `POST /v1/evaluate`, `POST /v1/plan-turn`, `POST /v1/tool-check`, `POST /v1/policy-matrix`, and the existing browser `/api/*` routes. It writes JSONL audit records to `.sovereigncode/audit.jsonl` by default. + Smoke-check the UI routes without opening a browser: ```bash @@ -152,8 +175,9 @@ py -3 -m tinyluminax.products.sovereigncode.cli ui --smoke | Workspace Indexer | Builds a local map of files, policies, secrets, data classes, and repository ownership. | | Data Capsule PDP | Decides whether a request is allowed, denied, or allowed with obligations. | | Tool Broker | Wraps shell, file, git, network, package, and model actions with policy checks. | +| Policy API Service | Serves policy evaluation, turn planning, tool checks, policy matrix, and audit reads over local HTTP. | | LumynaX Runtime Adapter | Routes model calls to local GGUF, local API, or approved LumynaX model endpoints. | -| Audit Ledger | Stores decision records, prompt/output hashes, file diffs, and approval metadata. | +| Audit Ledger | Stores append-only JSONL decision records, prompt/output hashes, file diffs, and approval metadata. | | Operator Console | Shows the plan, policy decision, diff, tests, and approval gate before external effects. | | Policy Matrix | Evaluates common tool/action scenarios against the same Data Capsule. | | Provider Exporter | Emits OpenCode-compatible workspace config pointing through MaramaRoute. | @@ -180,6 +204,8 @@ py -3 -m tinyluminax.products.sovereigncode.cli ui --smoke | Personal profile capsule | `examples/capsule.personal-sovereignty-profile.json` | | Personal-memory request | `examples/request.personal-memory-read.json` | | Browser operator console | `python -m tinyluminax.products.sovereigncode.cli ui` | +| Local policy API service | `python -m tinyluminax.products.sovereigncode.cli serve` | +| Audit ledger reader | `python -m tinyluminax.products.sovereigncode.cli audit` | | Policy/tool matrix | `python -m tinyluminax.products.sovereigncode.cli policy-matrix` | | Tool gate check | `python -m tinyluminax.products.sovereigncode.cli tool-check` | | OpenCode workspace export | `python -m tinyluminax.products.sovereigncode.cli opencode-config` | @@ -191,7 +217,7 @@ The sovereignty model is inspired by the Data Capsule pattern described in the S ## Stage -This is an executable platform scaffold, not the final commercial application. The policy engine, router integration, CLI package, policy matrix, tool gate checks, capsule summaries, OpenCode config export, operator checklist, and browser operator console are working now; the live tool broker, terminal loop, and full autonomous agent loop are the next implementation stage. +This is a local runtime product surface, not the final commercial application. The policy engine, router integration, CLI package, policy matrix, tool gate checks, capsule summaries, OpenCode config export, operator checklist, browser operator console, local policy API, and persistent audit ledger are working now. The full terminal editing loop remains a later layer, but policy, routing, audit, and OpenCode-facing configuration are executable today. # AbteeX SovereignCode Product Blueprint diff --git a/SMOKE_TESTS.md b/SMOKE_TESTS.md index e47a054c36ad2bb2d93714b092676117d0f65cb6..fd160ba74abaaf263067b1b57b57b4ef35691578 100644 --- a/SMOKE_TESTS.md +++ b/SMOKE_TESTS.md @@ -6,8 +6,10 @@ Run from the package root: pip install -e . python quickstart.py python -m sovereigncode.cli ui --smoke +python -m sovereigncode.cli serve --smoke python -m sovereigncode.cli policy-matrix --capsule examples/capsule.restricted-nz-code.json --request examples/request.allowed-local-edit.json python -m sovereigncode.cli tool-check --capsule examples/capsule.restricted-nz-code.json --request examples/request.allowed-local-edit.json --tool-name workspace_reader --action read_context +python -m sovereigncode.cli audit --limit 5 python -m sovereigncode.cli evaluate --capsule examples/capsule.personal-sovereignty-profile.json --request examples/request.personal-memory-read.json ``` diff --git a/architecture.md b/architecture.md index b863e19fd2684d817b69f198a5cd13ea485de615..de66a62067373d5e66c4587d68094fa5600f6d0a 100644 --- a/architecture.md +++ b/architecture.md @@ -1,95 +1,95 @@ -# AbteeX SovereignCode Architecture - -## North Star - -SovereignCode should feel like a capable local coding agent, but every action must be accountable to data sovereignty and AI sovereignty controls. The product should never silently send sensitive code or governed data to a remote model, execute an external command, or publish a change without a visible decision trail. - -## Control Plane - -```text -User intent - -> Workspace indexer - -> Data Capsule resolver - -> Sovereignty policy decision point - -> LumynaX MaramaRoute model selection - -> Tool broker - -> Human review gate - -> Audit ledger -``` - -## Core Concepts - -### Data Capsule - -A Data Capsule is the policy envelope attached to a workspace, dataset, tenant, case, source file set, or prompt context. It carries: - -- `allowed_purposes` -- `denied_purposes` -- `resident_regions` -- `retention_days` -- `training_allowed` -- `export_allowed` -- `data_classes` -- `schema_context` -- `consent_record` - -### Policy Decision Point - -The policy decision point answers one question before every sensitive action: can this actor, for this purpose, in this region, using this model/tool, touch this capsule? - -The first implementation lives at `src/tinyluminax/products/sovereigncode/policy.py`. - -### Tool Broker - -The broker is the enforcement layer for: - -- Shell commands -- File writes -- Git commits -- Network calls -- Package installs -- Model calls -- Retrieval queries -- Training or distillation jobs - -Each tool call receives a decision: allow, deny, or allow with obligations. - -### Audit Ledger - -Every decision creates a record containing: - -- Capsule id -- Actor -- Purpose -- Action -- Model id -- Decision -- Reasons -- Obligations -- Request hash -- Timestamp - -The first implementation lives at `src/tinyluminax/products/sovereigncode/audit.py`. - -## Launch Milestones - -| Milestone | Outcome | -| --- | --- | -| P0 scaffold | Policy engine, audit records, CLI, examples, docs. | -| P1 terminal loop | Local terminal agent with plan/edit/test workflow. | -| P2 tool broker | Policy wrappers for shell, git, file writes, package installs, and HTTP. | -| P3 MaramaRoute integration | Sovereign model routing for every model call. | -| P4 workspace UI | Browser console showing plan, policy, diffs, tests, and approvals. | -| P5 enterprise controls | Tenant policies, SSO hooks, signed audit exports, policy packs. | - -## Aesthetic Direction - -The product should follow the AbteeX/LumynaX visual system: - -- White or warm paper background. -- Obsidian text. -- Warm amber accent. -- Thin rule-based layouts. -- Editorial headings. -- Mono labels for governance, provenance, and runtime details. -- No generic purple AI gradients. +# AbteeX SovereignCode Architecture + +## North Star + +SovereignCode should feel like a capable local coding agent, but every action must be accountable to data sovereignty and AI sovereignty controls. The product should never silently send sensitive code or governed data to a remote model, execute an external command, or publish a change without a visible decision trail. + +## Control Plane + +```text +User intent + -> Workspace indexer + -> Data Capsule resolver + -> Sovereignty policy decision point + -> LumynaX MaramaRoute model selection + -> Tool broker + -> Human review gate + -> Audit ledger +``` + +## Core Concepts + +### Data Capsule + +A Data Capsule is the policy envelope attached to a workspace, dataset, tenant, case, source file set, or prompt context. It carries: + +- `allowed_purposes` +- `denied_purposes` +- `resident_regions` +- `retention_days` +- `training_allowed` +- `export_allowed` +- `data_classes` +- `schema_context` +- `consent_record` + +### Policy Decision Point + +The policy decision point answers one question before every sensitive action: can this actor, for this purpose, in this region, using this model/tool, touch this capsule? + +The first implementation lives at `src/tinyluminax/products/sovereigncode/policy.py`. + +### Tool Broker + +The broker is the enforcement layer for: + +- Shell commands +- File writes +- Git commits +- Network calls +- Package installs +- Model calls +- Retrieval queries +- Training or distillation jobs + +Each tool call receives a decision: allow, deny, or allow with obligations. + +### Audit Ledger + +Every decision creates a record containing: + +- Capsule id +- Actor +- Purpose +- Action +- Model id +- Decision +- Reasons +- Obligations +- Request hash +- Timestamp + +The first implementation lives at `src/tinyluminax/products/sovereigncode/audit.py`. + +## Launch Milestones + +| Milestone | Outcome | +| --- | --- | +| P0 scaffold | Policy engine, audit records, CLI, examples, docs. | +| P1 terminal loop | Local terminal agent with plan/edit/test workflow. | +| P2 tool broker | Policy wrappers for shell, git, file writes, package installs, and HTTP. | +| P3 MaramaRoute integration | Sovereign model routing for every model call. | +| P4 workspace UI | Browser console showing plan, policy, diffs, tests, and approvals. | +| P5 enterprise controls | Tenant policies, SSO hooks, signed audit exports, policy packs. | + +## Aesthetic Direction + +The product should follow the AbteeX/LumynaX visual system: + +- White or warm paper background. +- Obsidian text. +- Warm amber accent. +- Thin rule-based layouts. +- Editorial headings. +- Mono labels for governance, provenance, and runtime details. +- No generic purple AI gradients. diff --git a/configs/default_policy.yaml b/configs/default_policy.yaml index 65d01fb77ebfaec1d859c96df8e6e8d455526c6b..fc5af8fd8a5cb31e710a633835b0fce7184276e3 100644 --- a/configs/default_policy.yaml +++ b/configs/default_policy.yaml @@ -1,26 +1,26 @@ -policy_id: abx-sovereigncode-default-v0 -default_jurisdiction: NZ -default_resident_regions: - - NZ -high_impact_sensitivity: - - personal - - restricted - - health - - iwi - - taonga -default_obligations: - - write_immutable_audit_record - - preserve_capsule_id_in_agent_trace - - show_diff_before_write_or_commit -denied_without_human_approval: - - delete_file - - execute_shell - - network_export - - publish - - commit -remote_model_rule: - restricted_data_requires_local_or_lumynax: true -training_rule: - requires_capsule_training_allowed: true -export_rule: - requires_capsule_export_allowed: true +policy_id: abx-sovereigncode-default-v0 +default_jurisdiction: NZ +default_resident_regions: + - NZ +high_impact_sensitivity: + - personal + - restricted + - health + - iwi + - taonga +default_obligations: + - write_immutable_audit_record + - preserve_capsule_id_in_agent_trace + - show_diff_before_write_or_commit +denied_without_human_approval: + - delete_file + - execute_shell + - network_export + - publish + - commit +remote_model_rule: + restricted_data_requires_local_or_lumynax: true +training_rule: + requires_capsule_training_allowed: true +export_rule: + requires_capsule_export_allowed: true diff --git a/configs/gateway.local.json b/configs/gateway.local.json new file mode 100644 index 0000000000000000000000000000000000000000..bf157d04a883814a48a79c6376f9cfa7e8be83b9 --- /dev/null +++ b/configs/gateway.local.json @@ -0,0 +1,13 @@ +{ + "mode": "route_only", + "prompt_retention": "not_stored_by_default", + "default_timeout_seconds": 120, + "backends": { + "example-local-openai-compatible": { + "type": "openai_compatible", + "base_url": "http://127.0.0.1:8000/v1", + "api_key_env": "", + "model": "local-model-id" + } + } +} diff --git a/configs/provider_aliases.yaml b/configs/provider_aliases.yaml index 6662ff15c2e5336e94582f461956e92f0a5ab36d..b0c9b863187b55008de960750bcabeb4d16f11a7 100644 --- a/configs/provider_aliases.yaml +++ b/configs/provider_aliases.yaml @@ -1,30 +1,30 @@ -provider_id: abteex-marama -base_path: /v1 -default_model_alias: lumynax/auto -aliases: - lumynax/auto: - task_type: general - requires_local: true - description: Select the best resident LumynaX model for the request. - lumynax/code: - task_type: code - requires_local: true - requires_json: true - description: Prefer coder-tagged LumynaX models with tool and JSON support. - lumynax/reasoning: - task_type: reasoning - requires_local: true - description: Prefer reasoning-tagged models inside residency constraints. - lumynax/multimodal: - task_type: multimodal - requires_local: false - description: Prefer text-plus-image LumynaX models when policy allows. -default_route: - jurisdiction: NZ - data_sensitivity: internal - min_context_tokens: 4096 - max_fallbacks: 3 -telemetry: - retain_prompt_by_default: false - retain_route_decision_days: 365 - hash_request_payload: true +provider_id: abteex-marama +base_path: /v1 +default_model_alias: lumynax/auto +aliases: + lumynax/auto: + task_type: general + requires_local: true + description: Select the best resident LumynaX model for the request. + lumynax/code: + task_type: code + requires_local: true + requires_json: true + description: Prefer coder-tagged LumynaX models with tool and JSON support. + lumynax/reasoning: + task_type: reasoning + requires_local: true + description: Prefer reasoning-tagged models inside residency constraints. + lumynax/multimodal: + task_type: multimodal + requires_local: false + description: Prefer text-plus-image LumynaX models when policy allows. +default_route: + jurisdiction: NZ + data_sensitivity: internal + min_context_tokens: 4096 + max_fallbacks: 3 +telemetry: + retain_prompt_by_default: false + retain_route_decision_days: 365 + hash_request_payload: true diff --git a/configs/routing_policy.yaml b/configs/routing_policy.yaml index 947eabe01b553dd2c38720e219c2d0acac84775c..8c44a75f18ae0368e32236e6c620aa6a4d5ceb14 100644 --- a/configs/routing_policy.yaml +++ b/configs/routing_policy.yaml @@ -1,20 +1,20 @@ -policy_id: lumynax-marama-route-default-v0 -default_jurisdiction: NZ -default_requires_local: true -high_sensitivity: - - personal - - restricted - - health - - iwi - - taonga -required_for_high_sensitivity: - min_sovereignty_tier: 2 - residency_must_match_request_jurisdiction: true - retain_prompt_by_default: false -preferred_runtimes: - - llama_cpp - - transformers_multimodal - - python_embedding -fallbacks: - max_default_fallbacks: 3 - include_rejection_reasons: true +policy_id: lumynax-marama-route-default-v0 +default_jurisdiction: NZ +default_requires_local: true +high_sensitivity: + - personal + - restricted + - health + - iwi + - taonga +required_for_high_sensitivity: + min_sovereignty_tier: 2 + residency_must_match_request_jurisdiction: true + retain_prompt_by_default: false +preferred_runtimes: + - llama_cpp + - transformers_multimodal + - python_embedding +fallbacks: + max_default_fallbacks: 3 + include_rejection_reasons: true diff --git a/configs/service.local.json b/configs/service.local.json new file mode 100644 index 0000000000000000000000000000000000000000..1ca64dc192a57fcb4d421a6c053aac2619781e0b --- /dev/null +++ b/configs/service.local.json @@ -0,0 +1,14 @@ +{ + "ledger_path": ".sovereigncode/audit.jsonl", + "default_region": "NZ", + "fail_closed": true, + "require_human_review_for": [ + "write_files", + "execute_shell", + "network_export", + "commit", + "publish", + "train_model" + ], + "marama_route_base_url": "http://127.0.0.1:8787/v1" +} diff --git a/examples/capsule.personal-sovereignty-profile.json b/examples/capsule.personal-sovereignty-profile.json index 0822ee8ac5d97212c058f27b3a621093442a29fd..1d191825fd0f4d32397adb6d476401322dcc6af3 100644 --- a/examples/capsule.personal-sovereignty-profile.json +++ b/examples/capsule.personal-sovereignty-profile.json @@ -1,45 +1,45 @@ -{ - "capsule_id": "cap-personal-profile-001", - "subject_id": "operator-local-profile", - "jurisdiction": "NZ", - "sensitivity": "personal", - "allowed_purposes": [ - "personal_memory", - "coding_assistance", - "inference" - ], - "denied_purposes": [ - "ad_training", - "third_party_resale", - "public_leaderboard" - ], - "resident_regions": [ - "NZ" - ], - "data_classes": [ - "personal", - "preferences", - "source_code", - "runtime_logs" - ], - "retention_days": 7, - "export_allowed": false, - "training_allowed": false, - "personal_detail_level": "pseudonymous", - "consent_scopes": [ - "personal_memory", - "coding_assistance" - ], - "data_subject_rights": [ - "access", - "correction", - "deletion_request", - "processing_objection" - ], - "schema_context": "https://schema.org", - "consent_record": "local-profile-consent-v0", - "metadata": { - "storage": "local_encrypted_profile_store", - "prompt_rule": "summarise preferences without exposing raw personal notes" - } -} +{ + "capsule_id": "cap-personal-profile-001", + "subject_id": "operator-local-profile", + "jurisdiction": "NZ", + "sensitivity": "personal", + "allowed_purposes": [ + "personal_memory", + "coding_assistance", + "inference" + ], + "denied_purposes": [ + "ad_training", + "third_party_resale", + "public_leaderboard" + ], + "resident_regions": [ + "NZ" + ], + "data_classes": [ + "personal", + "preferences", + "source_code", + "runtime_logs" + ], + "retention_days": 7, + "export_allowed": false, + "training_allowed": false, + "personal_detail_level": "pseudonymous", + "consent_scopes": [ + "personal_memory", + "coding_assistance" + ], + "data_subject_rights": [ + "access", + "correction", + "deletion_request", + "processing_objection" + ], + "schema_context": "https://schema.org", + "consent_record": "local-profile-consent-v0", + "metadata": { + "storage": "local_encrypted_profile_store", + "prompt_rule": "summarise preferences without exposing raw personal notes" + } +} diff --git a/examples/capsule.restricted-nz-code.json b/examples/capsule.restricted-nz-code.json index 450943e104ed2dbfef4446b5373d40e0f0976f70..fac9db716019dec0decdcc18f1c38410c9090ab2 100644 --- a/examples/capsule.restricted-nz-code.json +++ b/examples/capsule.restricted-nz-code.json @@ -1,28 +1,28 @@ -{ - "capsule_id": "cap-nz-code-001", - "subject_id": "abx-workspace", - "jurisdiction": "NZ", - "sensitivity": "restricted", - "allowed_purposes": [ - "coding_assistance", - "inference", - "test_generation" - ], - "denied_purposes": [ - "ad_training", - "third_party_resale" - ], - "resident_regions": [ - "NZ" - ], - "data_classes": [ - "source_code", - "policy", - "runtime_logs" - ], - "retention_days": 14, - "export_allowed": false, - "training_allowed": false, - "schema_context": "https://schema.org", - "consent_record": "local-operator-policy-v0" -} +{ + "capsule_id": "cap-nz-code-001", + "subject_id": "abx-workspace", + "jurisdiction": "NZ", + "sensitivity": "restricted", + "allowed_purposes": [ + "coding_assistance", + "inference", + "test_generation" + ], + "denied_purposes": [ + "ad_training", + "third_party_resale" + ], + "resident_regions": [ + "NZ" + ], + "data_classes": [ + "source_code", + "policy", + "runtime_logs" + ], + "retention_days": 14, + "export_allowed": false, + "training_allowed": false, + "schema_context": "https://schema.org", + "consent_record": "local-operator-policy-v0" +} diff --git a/examples/opencode.marama-route.json b/examples/opencode.marama-route.json index 56755b01f178e8b3eaf84506355ba76a6a012636..368950327273587223a097dadf52622907f96044 100644 --- a/examples/opencode.marama-route.json +++ b/examples/opencode.marama-route.json @@ -1,28 +1,28 @@ -{ - "$schema": "https://opencode.ai/config.json", - "provider": { - "abteex-marama": { - "npm": "@ai-sdk/openai-compatible", - "name": "AbteeX MaramaRoute", - "options": { - "baseURL": "http://127.0.0.1:8787/v1", - "apiKey": "{env:ABTEEX_MARAMA_API_KEY}", - "headers": { - "X-AbteeX-Tenant": "{env:ABTEEX_TENANT_ID}", - "X-AbteeX-Workspace-Capsule": "{env:SOVEREIGNCODE_CAPSULE_ID}" - } - }, - "models": { - "lumynax/auto": { - "name": "LumynaX Auto Sovereign Route" - }, - "lumynax/code": { - "name": "LumynaX Code Route" - }, - "lumynax/reasoning": { - "name": "LumynaX Reasoning Route" - } - } - } - } -} +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "abteex-marama": { + "npm": "@ai-sdk/openai-compatible", + "name": "AbteeX MaramaRoute", + "options": { + "baseURL": "http://127.0.0.1:8787/v1", + "apiKey": "{env:ABTEEX_MARAMA_API_KEY}", + "headers": { + "X-AbteeX-Tenant": "{env:ABTEEX_TENANT_ID}", + "X-AbteeX-Workspace-Capsule": "{env:SOVEREIGNCODE_CAPSULE_ID}" + } + }, + "models": { + "lumynax/auto": { + "name": "LumynaX Auto Sovereign Route" + }, + "lumynax/code": { + "name": "LumynaX Code Route" + }, + "lumynax/reasoning": { + "name": "LumynaX Reasoning Route" + } + } + } + } +} diff --git a/examples/request.allowed-local-edit.json b/examples/request.allowed-local-edit.json index f7c6b2b49fa5b87c0a92c865ef468bef91359f84..75099a33e143f9cfab9357283a1797a65c790090 100644 --- a/examples/request.allowed-local-edit.json +++ b/examples/request.allowed-local-edit.json @@ -1,15 +1,15 @@ -{ - "actor": "developer", - "purpose": "coding_assistance", - "action": "read_context", - "region": "NZ", - "model_id": "AbteeXAILab/lumynax-infused-qwen3-8b-gguf", - "data_classes": [ - "source_code" - ], - "tool_name": "workspace_reader", - "writes_files": false, - "exports_data": false, - "trains_model": false, - "human_approved": false -} +{ + "actor": "developer", + "purpose": "coding_assistance", + "action": "read_context", + "region": "NZ", + "model_id": "AbteeXAILab/lumynax-infused-qwen3-8b-gguf", + "data_classes": [ + "source_code" + ], + "tool_name": "workspace_reader", + "writes_files": false, + "exports_data": false, + "trains_model": false, + "human_approved": false +} diff --git a/examples/request.code-restricted.json b/examples/request.code-restricted.json index a52b303e6839ac2e8837049f30f4cc0427864d37..8af0ca71616e49a78f1e5241d8763c7d0ff96d94 100644 --- a/examples/request.code-restricted.json +++ b/examples/request.code-restricted.json @@ -1,14 +1,14 @@ -{ - "prompt": "Refactor this private Python service and explain the diff.", - "task_type": "code", - "modalities": [ - "text" - ], - "jurisdiction": "NZ", - "data_sensitivity": "restricted", - "min_context_tokens": 4096, - "requires_local": true, - "requires_tools": false, - "requires_json": true, - "max_fallbacks": 3 -} +{ + "prompt": "Refactor this private Python service and explain the diff.", + "task_type": "code", + "modalities": [ + "text" + ], + "jurisdiction": "NZ", + "data_sensitivity": "restricted", + "min_context_tokens": 4096, + "requires_local": true, + "requires_tools": false, + "requires_json": true, + "max_fallbacks": 3 +} diff --git a/examples/request.denied-training.json b/examples/request.denied-training.json index 5665c131411f60fe6488833743303f5e936e8060..264055a6d05c224a90472db24b5c156773b773d1 100644 --- a/examples/request.denied-training.json +++ b/examples/request.denied-training.json @@ -1,15 +1,15 @@ -{ - "actor": "developer", - "purpose": "coding_assistance", - "action": "train_adapter", - "region": "NZ", - "model_id": "local/lumynax", - "data_classes": [ - "source_code" - ], - "tool_name": "trainer", - "writes_files": true, - "exports_data": false, - "trains_model": true, - "human_approved": true -} +{ + "actor": "developer", + "purpose": "coding_assistance", + "action": "train_adapter", + "region": "NZ", + "model_id": "local/lumynax", + "data_classes": [ + "source_code" + ], + "tool_name": "trainer", + "writes_files": true, + "exports_data": false, + "trains_model": true, + "human_approved": true +} diff --git a/examples/request.openai-chat-code.json b/examples/request.openai-chat-code.json index 252dc5d29b7e5a0559a48f8525585b6b4434a6d0..379b86aa32efff2ba361074435c335bbedeb91cf 100644 --- a/examples/request.openai-chat-code.json +++ b/examples/request.openai-chat-code.json @@ -1,44 +1,44 @@ -{ - "model": "lumynax/code", - "messages": [ - { - "role": "system", - "content": "You are a governed coding assistant for a New Zealand workspace." - }, - { - "role": "user", - "content": "Refactor this private Python repository function and return a JSON diff plan." - } - ], - "response_format": { - "type": "json_object" - }, - "tools": [ - { - "type": "function", - "function": { - "name": "propose_patch", - "description": "Propose a patch without writing it.", - "parameters": { - "type": "object", - "properties": { - "files": { - "type": "array", - "items": { "type": "string" } - } - } - } - } - } - ], - "route": { - "jurisdiction": "NZ", - "data_sensitivity": "restricted", - "task_type": "code", - "requires_local": true, - "requires_tools": true, - "requires_json": true, - "min_context_tokens": 4096, - "max_fallbacks": 3 - } -} +{ + "model": "lumynax/code", + "messages": [ + { + "role": "system", + "content": "You are a governed coding assistant for a New Zealand workspace." + }, + { + "role": "user", + "content": "Refactor this private Python repository function and return a JSON diff plan." + } + ], + "response_format": { + "type": "json_object" + }, + "tools": [ + { + "type": "function", + "function": { + "name": "propose_patch", + "description": "Propose a patch without writing it.", + "parameters": { + "type": "object", + "properties": { + "files": { + "type": "array", + "items": { "type": "string" } + } + } + } + } + } + ], + "route": { + "jurisdiction": "NZ", + "data_sensitivity": "restricted", + "task_type": "code", + "requires_local": true, + "requires_tools": true, + "requires_json": true, + "min_context_tokens": 4096, + "max_fallbacks": 3 + } +} diff --git a/examples/request.personal-memory-read.json b/examples/request.personal-memory-read.json index 61d6a53d199cefcee18c0368af5ea32a0e267ca9..7a67f6595ec9d46cea3c3d18af81e53974154d78 100644 --- a/examples/request.personal-memory-read.json +++ b/examples/request.personal-memory-read.json @@ -1,19 +1,19 @@ -{ - "actor": "developer", - "purpose": "personal_memory", - "action": "read_context", - "region": "NZ", - "model_id": "local/lumynax", - "data_classes": [ - "personal", - "preferences" - ], - "tool_name": "personal_profile_reader", - "writes_files": false, - "exports_data": false, - "trains_model": false, - "human_approved": false, - "personal_detail_level": "pseudonymous", - "consent_scope": "personal_memory", - "requested_retention_days": 7 -} +{ + "actor": "developer", + "purpose": "personal_memory", + "action": "read_context", + "region": "NZ", + "model_id": "local/lumynax", + "data_classes": [ + "personal", + "preferences" + ], + "tool_name": "personal_profile_reader", + "writes_files": false, + "exports_data": false, + "trains_model": false, + "human_approved": false, + "personal_detail_level": "pseudonymous", + "consent_scope": "personal_memory", + "requested_retention_days": 7 +} diff --git a/integrations/opencode-compatible-provider.md b/integrations/opencode-compatible-provider.md index 9877da8213a7f0b489aa9fe086d8d7f593323893..06ca743666af12417a425086c987c4c952fb207a 100644 --- a/integrations/opencode-compatible-provider.md +++ b/integrations/opencode-compatible-provider.md @@ -1,96 +1,96 @@ -# OpenCode-Compatible Provider Integration - -## Goal - -Make AbteeX SovereignCode usable from OpenCode and similar coding agents without -requiring those tools to understand Data Capsules directly. - -The integration shape is: - -```text -OpenCode - -> OpenAI-compatible provider config - -> MaramaRoute gateway `/v1` - -> SovereignCode policy and tool broker - -> LumynaX model runtime -``` - -## Current Compatibility Target - -OpenCode supports custom OpenAI-compatible providers through -`@ai-sdk/openai-compatible` and a provider `baseURL`. OpenRouter exposes an -OpenAI-like chat endpoint at `/api/v1/chat/completions`, with normalized request -and response payloads. MaramaRoute should therefore expose: - -- `GET /v1/models` -- `POST /v1/chat/completions` -- `POST /v1/route` -- `GET /v1/route/{decision_id}` - -References checked on 2026-05-17: - -- https://opencode.ai/docs/providers -- https://openrouter.ai/docs/api-reference/overview/ -- https://openrouter.ai/docs/api-reference/chat-completion - -## OpenCode Provider Config - -Use `examples/opencode.marama-route.json` as the project-local provider file. - -The important fields are: - -| Field | Value | -| --- | --- | -| `provider.abteex-marama.npm` | `@ai-sdk/openai-compatible` | -| `provider.abteex-marama.options.baseURL` | Local or hosted MaramaRoute `/v1` URL | -| `provider.abteex-marama.options.apiKey` | Environment backed key | -| `provider.abteex-marama.models` | LumynaX model aliases exposed by MaramaRoute | - -## SovereignCode Responsibilities - -OpenCode sends a normal chat request. SovereignCode and MaramaRoute add: - -- capsule resolution from workspace policy files -- purpose and personal-detail checks before prompt assembly -- model routing based on residency, modality, task, and sensitivity -- visible approval gates before file writes, shell commands, network export, or commit -- audit records for policy decisions and route decisions - -## Workspace Files - -A governed workspace should carry: - -```text -.sovereigncode/ - capsule.json - tenant-policy.yaml - approvals/ - audit/ -opencode.json -``` - -The agent can start with `capsule.json` and `opencode.json`. The full tool -broker can add approvals and audit persistence in the next build stage. - -## Minimum Viable Flow - -1. User opens a project in OpenCode. -2. OpenCode uses the `abteex-marama` provider. -3. MaramaRoute dry-runs the chat payload and selects a LumynaX model. -4. SovereignCode checks the workspace Data Capsule before exposing context. -5. The coding agent proposes a plan. -6. File writes require a visible diff and an audit record. -7. Shell, network, commit, and publish actions require explicit approval. - -## Similar Clients - -Any client that can point at an OpenAI-compatible endpoint should use the same -gateway: - -| Client type | Expected integration | -| --- | --- | -| OpenCode | `opencode.json` custom provider | -| Continue-style IDE assistant | OpenAI-compatible base URL and model ids | -| Aider-style terminal assistant | OpenAI-compatible base URL and key | -| Internal agent runner | Direct `/v1/route` and `/v1/chat/completions` calls | -| Browser console | Same API behind tenant auth | +# OpenCode-Compatible Provider Integration + +## Goal + +Make AbteeX SovereignCode usable from OpenCode and similar coding agents without +requiring those tools to understand Data Capsules directly. + +The integration shape is: + +```text +OpenCode + -> OpenAI-compatible provider config + -> MaramaRoute gateway `/v1` + -> SovereignCode policy and tool broker + -> LumynaX model runtime +``` + +## Current Compatibility Target + +OpenCode supports custom OpenAI-compatible providers through +`@ai-sdk/openai-compatible` and a provider `baseURL`. OpenRouter exposes an +OpenAI-like chat endpoint at `/api/v1/chat/completions`, with normalized request +and response payloads. MaramaRoute should therefore expose: + +- `GET /v1/models` +- `POST /v1/chat/completions` +- `POST /v1/route` +- `GET /v1/route/{decision_id}` + +References checked on 2026-05-17: + +- https://opencode.ai/docs/providers +- https://openrouter.ai/docs/api-reference/overview/ +- https://openrouter.ai/docs/api-reference/chat-completion + +## OpenCode Provider Config + +Use `examples/opencode.marama-route.json` as the project-local provider file. + +The important fields are: + +| Field | Value | +| --- | --- | +| `provider.abteex-marama.npm` | `@ai-sdk/openai-compatible` | +| `provider.abteex-marama.options.baseURL` | Local or hosted MaramaRoute `/v1` URL | +| `provider.abteex-marama.options.apiKey` | Environment backed key | +| `provider.abteex-marama.models` | LumynaX model aliases exposed by MaramaRoute | + +## SovereignCode Responsibilities + +OpenCode sends a normal chat request. SovereignCode and MaramaRoute add: + +- capsule resolution from workspace policy files +- purpose and personal-detail checks before prompt assembly +- model routing based on residency, modality, task, and sensitivity +- visible approval gates before file writes, shell commands, network export, or commit +- audit records for policy decisions and route decisions + +## Workspace Files + +A governed workspace should carry: + +```text +.sovereigncode/ + capsule.json + tenant-policy.yaml + approvals/ + audit/ +opencode.json +``` + +The agent can start with `capsule.json` and `opencode.json`. The full tool +broker can add approvals and audit persistence in the next build stage. + +## Minimum Viable Flow + +1. User opens a project in OpenCode. +2. OpenCode uses the `abteex-marama` provider. +3. MaramaRoute dry-runs the chat payload and selects a LumynaX model. +4. SovereignCode checks the workspace Data Capsule before exposing context. +5. The coding agent proposes a plan. +6. File writes require a visible diff and an audit record. +7. Shell, network, commit, and publish actions require explicit approval. + +## Similar Clients + +Any client that can point at an OpenAI-compatible endpoint should use the same +gateway: + +| Client type | Expected integration | +| --- | --- | +| OpenCode | `opencode.json` custom provider | +| Continue-style IDE assistant | OpenAI-compatible base URL and model ids | +| Aider-style terminal assistant | OpenAI-compatible base URL and key | +| Internal agent runner | Direct `/v1/route` and `/v1/chat/completions` calls | +| Browser console | Same API behind tenant auth | diff --git a/integrations/opencode-provider.json b/integrations/opencode-provider.json index 02b278a31d185e8e620ccbdc65576fb437424288..05c32a5c558c8aaaff9a8dac194e099da29e7391 100644 --- a/integrations/opencode-provider.json +++ b/integrations/opencode-provider.json @@ -1,28 +1,28 @@ -{ - "$schema": "https://opencode.ai/config.json", - "provider": { - "abteex-marama": { - "npm": "@ai-sdk/openai-compatible", - "name": "AbteeX MaramaRoute", - "options": { - "baseURL": "http://127.0.0.1:8787/v1", - "apiKey": "{env:ABTEEX_MARAMA_API_KEY}", - "headers": { - "X-AbteeX-Route-Jurisdiction": "NZ", - "X-AbteeX-Route-Sensitivity": "restricted" - } - }, - "models": { - "lumynax/auto": { - "name": "LumynaX Auto" - }, - "lumynax/code": { - "name": "LumynaX Code" - }, - "lumynax/local": { - "name": "LumynaX Local" - } - } - } - } -} +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "abteex-marama": { + "npm": "@ai-sdk/openai-compatible", + "name": "AbteeX MaramaRoute", + "options": { + "baseURL": "http://127.0.0.1:8787/v1", + "apiKey": "{env:ABTEEX_MARAMA_API_KEY}", + "headers": { + "X-AbteeX-Route-Jurisdiction": "NZ", + "X-AbteeX-Route-Sensitivity": "restricted" + } + }, + "models": { + "lumynax/auto": { + "name": "LumynaX Auto" + }, + "lumynax/code": { + "name": "LumynaX Code" + }, + "lumynax/local": { + "name": "LumynaX Local" + } + } + } + } +} diff --git a/marama_route/__init__.py b/marama_route/__init__.py index 95e79067f043319e931c9ec8af8acf03c66339ad..a09890e68647a0856a3d8d7aa1acd73997604071 100644 --- a/marama_route/__init__.py +++ b/marama_route/__init__.py @@ -15,6 +15,7 @@ from .platform import ( ) from .registry import ModelEndpoint, RoutingRequest, load_model_registry from .router import RouteDecision, SovereignModelRouter +from .server import handle_gateway_request, load_gateway_config, smoke_gateway from .ui import smoke_ui as smoke_ui __all__ = [ @@ -28,9 +29,12 @@ __all__ = [ "build_registry_analytics", "catalog_models", "compare_models", + "handle_gateway_request", + "load_gateway_config", "load_model_registry", "route_chat_payload", "route_scenario_matrix", "routing_request_from_chat_payload", + "smoke_gateway", "smoke_ui", ] diff --git a/marama_route/_ui_server.py b/marama_route/_ui_server.py index 140a1e907215d114163d02e284d81737df374630..e4b6da2ca0fbac971424b86afd9df28affcd9401 100644 --- a/marama_route/_ui_server.py +++ b/marama_route/_ui_server.py @@ -37,8 +37,14 @@ def serve_dashboard( host: str, port: int, open_browser: bool = False, + api_path_prefixes: tuple[str, ...] = ("/api/",), + api_exact_paths: tuple[str, ...] = (), ) -> int: actual_port = find_available_port(host, port) + exact_paths = set(api_exact_paths) + + def is_api_path(path: str) -> bool: + return path in exact_paths or any(path.startswith(prefix) for prefix in api_path_prefixes) class Handler(BaseHTTPRequestHandler): server_version = "AbteeXProductUI/0.1" @@ -48,14 +54,14 @@ def serve_dashboard( if path == "/": self._send_text(200, html, "text/html; charset=utf-8") return - if path.startswith("/api/"): + if is_api_path(path): self._send_api("GET", path, None) return self._send_json(404, {"ok": False, "error": "not_found"}) def do_POST(self) -> None: # noqa: N802 - stdlib handler method name path = urlparse(self.path).path - if not path.startswith("/api/"): + if not is_api_path(path): self._send_json(404, {"ok": False, "error": "not_found"}) return try: diff --git a/marama_route/cli.py b/marama_route/cli.py index 69104e4bea2649e9bb100946770177cb4fc3b318..85151f118135d332f0b26b95836a0031a12d24f8 100644 --- a/marama_route/cli.py +++ b/marama_route/cli.py @@ -109,6 +109,19 @@ def _ui(args: argparse.Namespace) -> int: ) +def _serve(args: argparse.Namespace) -> int: + from .server import serve_gateway + + return serve_gateway( + registry_path=args.registry, + config_path=args.config, + host=args.host, + port=args.port, + open_browser=args.open, + smoke=args.smoke, + ) + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="lumynax-marama-route", @@ -191,6 +204,18 @@ def build_parser() -> argparse.ArgumentParser: ui.add_argument("--open", action=argparse.BooleanOptionalAction, default=False) ui.add_argument("--smoke", action=argparse.BooleanOptionalAction, default=False) ui.set_defaults(handler=_ui) + + serve = subparsers.add_parser( + "serve", + help="Run the local OpenAI-compatible MaramaRoute gateway and browser console.", + ) + serve.add_argument("--registry", type=Path, default=None, help="MaramaRoute model registry JSON.") + serve.add_argument("--config", type=Path, default=None, help="Gateway backend config JSON.") + serve.add_argument("--host", type=str, default="127.0.0.1") + serve.add_argument("--port", type=int, default=8787) + serve.add_argument("--open", action=argparse.BooleanOptionalAction, default=False) + serve.add_argument("--smoke", action=argparse.BooleanOptionalAction, default=False) + serve.set_defaults(handler=_serve) return parser diff --git a/marama_route/configs/default_policy.yaml b/marama_route/configs/default_policy.yaml index 65d01fb77ebfaec1d859c96df8e6e8d455526c6b..fc5af8fd8a5cb31e710a633835b0fce7184276e3 100644 --- a/marama_route/configs/default_policy.yaml +++ b/marama_route/configs/default_policy.yaml @@ -1,26 +1,26 @@ -policy_id: abx-sovereigncode-default-v0 -default_jurisdiction: NZ -default_resident_regions: - - NZ -high_impact_sensitivity: - - personal - - restricted - - health - - iwi - - taonga -default_obligations: - - write_immutable_audit_record - - preserve_capsule_id_in_agent_trace - - show_diff_before_write_or_commit -denied_without_human_approval: - - delete_file - - execute_shell - - network_export - - publish - - commit -remote_model_rule: - restricted_data_requires_local_or_lumynax: true -training_rule: - requires_capsule_training_allowed: true -export_rule: - requires_capsule_export_allowed: true +policy_id: abx-sovereigncode-default-v0 +default_jurisdiction: NZ +default_resident_regions: + - NZ +high_impact_sensitivity: + - personal + - restricted + - health + - iwi + - taonga +default_obligations: + - write_immutable_audit_record + - preserve_capsule_id_in_agent_trace + - show_diff_before_write_or_commit +denied_without_human_approval: + - delete_file + - execute_shell + - network_export + - publish + - commit +remote_model_rule: + restricted_data_requires_local_or_lumynax: true +training_rule: + requires_capsule_training_allowed: true +export_rule: + requires_capsule_export_allowed: true diff --git a/marama_route/configs/gateway.local.json b/marama_route/configs/gateway.local.json new file mode 100644 index 0000000000000000000000000000000000000000..bf157d04a883814a48a79c6376f9cfa7e8be83b9 --- /dev/null +++ b/marama_route/configs/gateway.local.json @@ -0,0 +1,13 @@ +{ + "mode": "route_only", + "prompt_retention": "not_stored_by_default", + "default_timeout_seconds": 120, + "backends": { + "example-local-openai-compatible": { + "type": "openai_compatible", + "base_url": "http://127.0.0.1:8000/v1", + "api_key_env": "", + "model": "local-model-id" + } + } +} diff --git a/marama_route/configs/provider_aliases.yaml b/marama_route/configs/provider_aliases.yaml index 6662ff15c2e5336e94582f461956e92f0a5ab36d..b0c9b863187b55008de960750bcabeb4d16f11a7 100644 --- a/marama_route/configs/provider_aliases.yaml +++ b/marama_route/configs/provider_aliases.yaml @@ -1,30 +1,30 @@ -provider_id: abteex-marama -base_path: /v1 -default_model_alias: lumynax/auto -aliases: - lumynax/auto: - task_type: general - requires_local: true - description: Select the best resident LumynaX model for the request. - lumynax/code: - task_type: code - requires_local: true - requires_json: true - description: Prefer coder-tagged LumynaX models with tool and JSON support. - lumynax/reasoning: - task_type: reasoning - requires_local: true - description: Prefer reasoning-tagged models inside residency constraints. - lumynax/multimodal: - task_type: multimodal - requires_local: false - description: Prefer text-plus-image LumynaX models when policy allows. -default_route: - jurisdiction: NZ - data_sensitivity: internal - min_context_tokens: 4096 - max_fallbacks: 3 -telemetry: - retain_prompt_by_default: false - retain_route_decision_days: 365 - hash_request_payload: true +provider_id: abteex-marama +base_path: /v1 +default_model_alias: lumynax/auto +aliases: + lumynax/auto: + task_type: general + requires_local: true + description: Select the best resident LumynaX model for the request. + lumynax/code: + task_type: code + requires_local: true + requires_json: true + description: Prefer coder-tagged LumynaX models with tool and JSON support. + lumynax/reasoning: + task_type: reasoning + requires_local: true + description: Prefer reasoning-tagged models inside residency constraints. + lumynax/multimodal: + task_type: multimodal + requires_local: false + description: Prefer text-plus-image LumynaX models when policy allows. +default_route: + jurisdiction: NZ + data_sensitivity: internal + min_context_tokens: 4096 + max_fallbacks: 3 +telemetry: + retain_prompt_by_default: false + retain_route_decision_days: 365 + hash_request_payload: true diff --git a/marama_route/configs/routing_policy.yaml b/marama_route/configs/routing_policy.yaml index 947eabe01b553dd2c38720e219c2d0acac84775c..8c44a75f18ae0368e32236e6c620aa6a4d5ceb14 100644 --- a/marama_route/configs/routing_policy.yaml +++ b/marama_route/configs/routing_policy.yaml @@ -1,20 +1,20 @@ -policy_id: lumynax-marama-route-default-v0 -default_jurisdiction: NZ -default_requires_local: true -high_sensitivity: - - personal - - restricted - - health - - iwi - - taonga -required_for_high_sensitivity: - min_sovereignty_tier: 2 - residency_must_match_request_jurisdiction: true - retain_prompt_by_default: false -preferred_runtimes: - - llama_cpp - - transformers_multimodal - - python_embedding -fallbacks: - max_default_fallbacks: 3 - include_rejection_reasons: true +policy_id: lumynax-marama-route-default-v0 +default_jurisdiction: NZ +default_requires_local: true +high_sensitivity: + - personal + - restricted + - health + - iwi + - taonga +required_for_high_sensitivity: + min_sovereignty_tier: 2 + residency_must_match_request_jurisdiction: true + retain_prompt_by_default: false +preferred_runtimes: + - llama_cpp + - transformers_multimodal + - python_embedding +fallbacks: + max_default_fallbacks: 3 + include_rejection_reasons: true diff --git a/marama_route/configs/service.local.json b/marama_route/configs/service.local.json new file mode 100644 index 0000000000000000000000000000000000000000..1ca64dc192a57fcb4d421a6c053aac2619781e0b --- /dev/null +++ b/marama_route/configs/service.local.json @@ -0,0 +1,14 @@ +{ + "ledger_path": ".sovereigncode/audit.jsonl", + "default_region": "NZ", + "fail_closed": true, + "require_human_review_for": [ + "write_files", + "execute_shell", + "network_export", + "commit", + "publish", + "train_model" + ], + "marama_route_base_url": "http://127.0.0.1:8787/v1" +} diff --git a/marama_route/examples/capsule.personal-sovereignty-profile.json b/marama_route/examples/capsule.personal-sovereignty-profile.json index 0822ee8ac5d97212c058f27b3a621093442a29fd..1d191825fd0f4d32397adb6d476401322dcc6af3 100644 --- a/marama_route/examples/capsule.personal-sovereignty-profile.json +++ b/marama_route/examples/capsule.personal-sovereignty-profile.json @@ -1,45 +1,45 @@ -{ - "capsule_id": "cap-personal-profile-001", - "subject_id": "operator-local-profile", - "jurisdiction": "NZ", - "sensitivity": "personal", - "allowed_purposes": [ - "personal_memory", - "coding_assistance", - "inference" - ], - "denied_purposes": [ - "ad_training", - "third_party_resale", - "public_leaderboard" - ], - "resident_regions": [ - "NZ" - ], - "data_classes": [ - "personal", - "preferences", - "source_code", - "runtime_logs" - ], - "retention_days": 7, - "export_allowed": false, - "training_allowed": false, - "personal_detail_level": "pseudonymous", - "consent_scopes": [ - "personal_memory", - "coding_assistance" - ], - "data_subject_rights": [ - "access", - "correction", - "deletion_request", - "processing_objection" - ], - "schema_context": "https://schema.org", - "consent_record": "local-profile-consent-v0", - "metadata": { - "storage": "local_encrypted_profile_store", - "prompt_rule": "summarise preferences without exposing raw personal notes" - } -} +{ + "capsule_id": "cap-personal-profile-001", + "subject_id": "operator-local-profile", + "jurisdiction": "NZ", + "sensitivity": "personal", + "allowed_purposes": [ + "personal_memory", + "coding_assistance", + "inference" + ], + "denied_purposes": [ + "ad_training", + "third_party_resale", + "public_leaderboard" + ], + "resident_regions": [ + "NZ" + ], + "data_classes": [ + "personal", + "preferences", + "source_code", + "runtime_logs" + ], + "retention_days": 7, + "export_allowed": false, + "training_allowed": false, + "personal_detail_level": "pseudonymous", + "consent_scopes": [ + "personal_memory", + "coding_assistance" + ], + "data_subject_rights": [ + "access", + "correction", + "deletion_request", + "processing_objection" + ], + "schema_context": "https://schema.org", + "consent_record": "local-profile-consent-v0", + "metadata": { + "storage": "local_encrypted_profile_store", + "prompt_rule": "summarise preferences without exposing raw personal notes" + } +} diff --git a/marama_route/examples/capsule.restricted-nz-code.json b/marama_route/examples/capsule.restricted-nz-code.json index 450943e104ed2dbfef4446b5373d40e0f0976f70..fac9db716019dec0decdcc18f1c38410c9090ab2 100644 --- a/marama_route/examples/capsule.restricted-nz-code.json +++ b/marama_route/examples/capsule.restricted-nz-code.json @@ -1,28 +1,28 @@ -{ - "capsule_id": "cap-nz-code-001", - "subject_id": "abx-workspace", - "jurisdiction": "NZ", - "sensitivity": "restricted", - "allowed_purposes": [ - "coding_assistance", - "inference", - "test_generation" - ], - "denied_purposes": [ - "ad_training", - "third_party_resale" - ], - "resident_regions": [ - "NZ" - ], - "data_classes": [ - "source_code", - "policy", - "runtime_logs" - ], - "retention_days": 14, - "export_allowed": false, - "training_allowed": false, - "schema_context": "https://schema.org", - "consent_record": "local-operator-policy-v0" -} +{ + "capsule_id": "cap-nz-code-001", + "subject_id": "abx-workspace", + "jurisdiction": "NZ", + "sensitivity": "restricted", + "allowed_purposes": [ + "coding_assistance", + "inference", + "test_generation" + ], + "denied_purposes": [ + "ad_training", + "third_party_resale" + ], + "resident_regions": [ + "NZ" + ], + "data_classes": [ + "source_code", + "policy", + "runtime_logs" + ], + "retention_days": 14, + "export_allowed": false, + "training_allowed": false, + "schema_context": "https://schema.org", + "consent_record": "local-operator-policy-v0" +} diff --git a/marama_route/examples/opencode.marama-route.json b/marama_route/examples/opencode.marama-route.json index 56755b01f178e8b3eaf84506355ba76a6a012636..368950327273587223a097dadf52622907f96044 100644 --- a/marama_route/examples/opencode.marama-route.json +++ b/marama_route/examples/opencode.marama-route.json @@ -1,28 +1,28 @@ -{ - "$schema": "https://opencode.ai/config.json", - "provider": { - "abteex-marama": { - "npm": "@ai-sdk/openai-compatible", - "name": "AbteeX MaramaRoute", - "options": { - "baseURL": "http://127.0.0.1:8787/v1", - "apiKey": "{env:ABTEEX_MARAMA_API_KEY}", - "headers": { - "X-AbteeX-Tenant": "{env:ABTEEX_TENANT_ID}", - "X-AbteeX-Workspace-Capsule": "{env:SOVEREIGNCODE_CAPSULE_ID}" - } - }, - "models": { - "lumynax/auto": { - "name": "LumynaX Auto Sovereign Route" - }, - "lumynax/code": { - "name": "LumynaX Code Route" - }, - "lumynax/reasoning": { - "name": "LumynaX Reasoning Route" - } - } - } - } -} +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "abteex-marama": { + "npm": "@ai-sdk/openai-compatible", + "name": "AbteeX MaramaRoute", + "options": { + "baseURL": "http://127.0.0.1:8787/v1", + "apiKey": "{env:ABTEEX_MARAMA_API_KEY}", + "headers": { + "X-AbteeX-Tenant": "{env:ABTEEX_TENANT_ID}", + "X-AbteeX-Workspace-Capsule": "{env:SOVEREIGNCODE_CAPSULE_ID}" + } + }, + "models": { + "lumynax/auto": { + "name": "LumynaX Auto Sovereign Route" + }, + "lumynax/code": { + "name": "LumynaX Code Route" + }, + "lumynax/reasoning": { + "name": "LumynaX Reasoning Route" + } + } + } + } +} diff --git a/marama_route/examples/request.allowed-local-edit.json b/marama_route/examples/request.allowed-local-edit.json index f7c6b2b49fa5b87c0a92c865ef468bef91359f84..75099a33e143f9cfab9357283a1797a65c790090 100644 --- a/marama_route/examples/request.allowed-local-edit.json +++ b/marama_route/examples/request.allowed-local-edit.json @@ -1,15 +1,15 @@ -{ - "actor": "developer", - "purpose": "coding_assistance", - "action": "read_context", - "region": "NZ", - "model_id": "AbteeXAILab/lumynax-infused-qwen3-8b-gguf", - "data_classes": [ - "source_code" - ], - "tool_name": "workspace_reader", - "writes_files": false, - "exports_data": false, - "trains_model": false, - "human_approved": false -} +{ + "actor": "developer", + "purpose": "coding_assistance", + "action": "read_context", + "region": "NZ", + "model_id": "AbteeXAILab/lumynax-infused-qwen3-8b-gguf", + "data_classes": [ + "source_code" + ], + "tool_name": "workspace_reader", + "writes_files": false, + "exports_data": false, + "trains_model": false, + "human_approved": false +} diff --git a/marama_route/examples/request.code-restricted.json b/marama_route/examples/request.code-restricted.json index a52b303e6839ac2e8837049f30f4cc0427864d37..8af0ca71616e49a78f1e5241d8763c7d0ff96d94 100644 --- a/marama_route/examples/request.code-restricted.json +++ b/marama_route/examples/request.code-restricted.json @@ -1,14 +1,14 @@ -{ - "prompt": "Refactor this private Python service and explain the diff.", - "task_type": "code", - "modalities": [ - "text" - ], - "jurisdiction": "NZ", - "data_sensitivity": "restricted", - "min_context_tokens": 4096, - "requires_local": true, - "requires_tools": false, - "requires_json": true, - "max_fallbacks": 3 -} +{ + "prompt": "Refactor this private Python service and explain the diff.", + "task_type": "code", + "modalities": [ + "text" + ], + "jurisdiction": "NZ", + "data_sensitivity": "restricted", + "min_context_tokens": 4096, + "requires_local": true, + "requires_tools": false, + "requires_json": true, + "max_fallbacks": 3 +} diff --git a/marama_route/examples/request.denied-training.json b/marama_route/examples/request.denied-training.json index 5665c131411f60fe6488833743303f5e936e8060..264055a6d05c224a90472db24b5c156773b773d1 100644 --- a/marama_route/examples/request.denied-training.json +++ b/marama_route/examples/request.denied-training.json @@ -1,15 +1,15 @@ -{ - "actor": "developer", - "purpose": "coding_assistance", - "action": "train_adapter", - "region": "NZ", - "model_id": "local/lumynax", - "data_classes": [ - "source_code" - ], - "tool_name": "trainer", - "writes_files": true, - "exports_data": false, - "trains_model": true, - "human_approved": true -} +{ + "actor": "developer", + "purpose": "coding_assistance", + "action": "train_adapter", + "region": "NZ", + "model_id": "local/lumynax", + "data_classes": [ + "source_code" + ], + "tool_name": "trainer", + "writes_files": true, + "exports_data": false, + "trains_model": true, + "human_approved": true +} diff --git a/marama_route/examples/request.openai-chat-code.json b/marama_route/examples/request.openai-chat-code.json index 252dc5d29b7e5a0559a48f8525585b6b4434a6d0..379b86aa32efff2ba361074435c335bbedeb91cf 100644 --- a/marama_route/examples/request.openai-chat-code.json +++ b/marama_route/examples/request.openai-chat-code.json @@ -1,44 +1,44 @@ -{ - "model": "lumynax/code", - "messages": [ - { - "role": "system", - "content": "You are a governed coding assistant for a New Zealand workspace." - }, - { - "role": "user", - "content": "Refactor this private Python repository function and return a JSON diff plan." - } - ], - "response_format": { - "type": "json_object" - }, - "tools": [ - { - "type": "function", - "function": { - "name": "propose_patch", - "description": "Propose a patch without writing it.", - "parameters": { - "type": "object", - "properties": { - "files": { - "type": "array", - "items": { "type": "string" } - } - } - } - } - } - ], - "route": { - "jurisdiction": "NZ", - "data_sensitivity": "restricted", - "task_type": "code", - "requires_local": true, - "requires_tools": true, - "requires_json": true, - "min_context_tokens": 4096, - "max_fallbacks": 3 - } -} +{ + "model": "lumynax/code", + "messages": [ + { + "role": "system", + "content": "You are a governed coding assistant for a New Zealand workspace." + }, + { + "role": "user", + "content": "Refactor this private Python repository function and return a JSON diff plan." + } + ], + "response_format": { + "type": "json_object" + }, + "tools": [ + { + "type": "function", + "function": { + "name": "propose_patch", + "description": "Propose a patch without writing it.", + "parameters": { + "type": "object", + "properties": { + "files": { + "type": "array", + "items": { "type": "string" } + } + } + } + } + } + ], + "route": { + "jurisdiction": "NZ", + "data_sensitivity": "restricted", + "task_type": "code", + "requires_local": true, + "requires_tools": true, + "requires_json": true, + "min_context_tokens": 4096, + "max_fallbacks": 3 + } +} diff --git a/marama_route/examples/request.personal-memory-read.json b/marama_route/examples/request.personal-memory-read.json index 61d6a53d199cefcee18c0368af5ea32a0e267ca9..7a67f6595ec9d46cea3c3d18af81e53974154d78 100644 --- a/marama_route/examples/request.personal-memory-read.json +++ b/marama_route/examples/request.personal-memory-read.json @@ -1,19 +1,19 @@ -{ - "actor": "developer", - "purpose": "personal_memory", - "action": "read_context", - "region": "NZ", - "model_id": "local/lumynax", - "data_classes": [ - "personal", - "preferences" - ], - "tool_name": "personal_profile_reader", - "writes_files": false, - "exports_data": false, - "trains_model": false, - "human_approved": false, - "personal_detail_level": "pseudonymous", - "consent_scope": "personal_memory", - "requested_retention_days": 7 -} +{ + "actor": "developer", + "purpose": "personal_memory", + "action": "read_context", + "region": "NZ", + "model_id": "local/lumynax", + "data_classes": [ + "personal", + "preferences" + ], + "tool_name": "personal_profile_reader", + "writes_files": false, + "exports_data": false, + "trains_model": false, + "human_approved": false, + "personal_detail_level": "pseudonymous", + "consent_scope": "personal_memory", + "requested_retention_days": 7 +} diff --git a/marama_route/gateway.py b/marama_route/gateway.py index cbedc1d1d050346aa5df2af0e4763493698168b1..75cc503a18b09cf1d924ae701781b5fc880ee1c7 100644 --- a/marama_route/gateway.py +++ b/marama_route/gateway.py @@ -1,204 +1,204 @@ -from __future__ import annotations - -import hashlib -import json -import time -from typing import Any - -from .registry import ModelEndpoint, RoutingRequest -from .router import RouteDecision, SovereignModelRouter - - -def routing_request_from_chat_payload(payload: dict[str, Any]) -> RoutingRequest: - """Translate an OpenAI-compatible chat request into a routing request.""" - - route_options = _mapping( - payload.get("route") - or payload.get("routing") - or _mapping(payload.get("metadata")).get("marama_route"), - ) - prompt, modalities = _prompt_and_modalities(payload) - tools = payload.get("tools") - response_format = _mapping(payload.get("response_format")) - task_type = str(route_options.get("task_type") or _infer_task_type(prompt, modalities)) - - return RoutingRequest.from_payload( - { - "prompt": prompt, - "task_type": task_type, - "modalities": sorted(modalities), - "jurisdiction": route_options.get("jurisdiction", "NZ"), - "data_sensitivity": route_options.get("data_sensitivity", "internal"), - "min_context_tokens": route_options.get("min_context_tokens", 4096), - "requires_local": route_options.get("requires_local", True), - "requires_tools": bool(tools) or bool(route_options.get("requires_tools")), - "requires_json": _requires_json(response_format, route_options), - "license_allowlist": route_options.get("license_allowlist", ()), - "max_fallbacks": route_options.get("max_fallbacks", 3), - "metadata": { - "requested_model": payload.get("model", "auto"), - "source_protocol": "openai_chat_completions", - }, - }, - ) - - -def build_models_response(models: tuple[ModelEndpoint, ...]) -> dict[str, Any]: - """Return an OpenAI-compatible `/v1/models` listing.""" - - return { - "object": "list", - "data": [ - { - "id": model.model_id, - "object": "model", - "created": 0, - "owned_by": model.repo_id.split("/", maxsplit=1)[0], - "metadata": { - "repo_id": model.repo_id, - "runtime": model.runtime, - "modalities": list(model.modalities), - "context_tokens": model.context_tokens, - "residency": list(model.residency), - "sovereignty_tier": model.sovereignty_tier, - "supports_tools": model.supports_tools, - "supports_json": model.supports_json, - "tags": list(model.tags), - }, - } - for model in models - ], - } - - -def build_chat_route_response( - payload: dict[str, Any], - decision: RouteDecision, -) -> dict[str, Any]: - """Return a dry-run chat response with route metadata and no generated text.""" - - selected = decision.selected_model - created = int(time.time()) - request_hash = hashlib.sha256( - json.dumps(payload, sort_keys=True, default=str).encode("utf-8"), - ).hexdigest() - model_id = selected.model_id if selected is not None else str(payload.get("model", "")) - - return { - "id": f"marama-route-{request_hash[:16]}", - "object": "chat.completion", - "created": created, - "model": model_id, - "choices": [ - { - "index": 0, - "finish_reason": "route_only", - "message": { - "role": "assistant", - "content": "", - }, - }, - ], - "usage": { - "prompt_tokens": 0, - "completion_tokens": 0, - "total_tokens": 0, - }, - "marama_route": { - "dry_run": True, - "selected_model": selected.to_dict() if selected is not None else None, - "fallback_models": [model.to_dict() for model in decision.fallback_models], - "rejected_count": len(decision.rejected), - "reasons": list(decision.reasons), - "scores": dict(decision.scores), - "request_hash": request_hash, - }, - } - - -def route_chat_payload( - payload: dict[str, Any], - models: tuple[ModelEndpoint, ...], -) -> dict[str, Any]: - request = routing_request_from_chat_payload(payload) - decision = SovereignModelRouter(models).route(request) - return { - "routing_request": request.to_dict(), - "route_decision": decision.to_dict(), - "chat_completion_response": build_chat_route_response(payload, decision), - } - - -def _mapping(value: object) -> dict[str, Any]: - return dict(value) if isinstance(value, dict) else {} - - -def _requires_json( - response_format: dict[str, Any], - route_options: dict[str, Any], -) -> bool: - if bool(route_options.get("requires_json")): - return True - response_type = str(response_format.get("type", "")).lower() - return response_type in {"json_object", "json_schema"} - - -def _prompt_and_modalities(payload: dict[str, Any]) -> tuple[str, set[str]]: - modalities = {"text"} - pieces: list[str] = [] - messages = payload.get("messages") - if isinstance(messages, list): - for message in messages: - if not isinstance(message, dict): - continue - content = message.get("content") - text, content_modalities = _content_text_and_modalities(content) - pieces.append(text) - modalities.update(content_modalities) - elif isinstance(payload.get("prompt"), str): - pieces.append(str(payload["prompt"])) - return "\n".join(piece for piece in pieces if piece), modalities - - -def _content_text_and_modalities(content: object) -> tuple[str, set[str]]: - if isinstance(content, str): - return content, {"text"} - if not isinstance(content, list): - return "", {"text"} - - pieces: list[str] = [] - modalities = {"text"} - for part in content: - if not isinstance(part, dict): - continue - part_type = str(part.get("type", "")).lower() - if part_type in {"text", "input_text"}: - pieces.append(str(part.get("text", ""))) - elif part_type in {"image", "image_url", "input_image"}: - modalities.add("image") - elif part_type in {"audio", "input_audio"}: - modalities.add("audio") - return "\n".join(piece for piece in pieces if piece), modalities - - -def _infer_task_type(prompt: str, modalities: set[str]) -> str: - prompt_lower = prompt.lower() - if "image" in modalities or "vision" in modalities: - return "multimodal" - code_markers = ( - "refactor", - "diff", - "unit test", - "python", - "typescript", - "javascript", - "repository", - "function", - "class ", - "stack trace", - ) - if any(marker in prompt_lower for marker in code_markers): - return "code" - if "reason" in prompt_lower or "prove" in prompt_lower: - return "reasoning" - return "general" +from __future__ import annotations + +import hashlib +import json +import time +from typing import Any + +from .registry import ModelEndpoint, RoutingRequest +from .router import RouteDecision, SovereignModelRouter + + +def routing_request_from_chat_payload(payload: dict[str, Any]) -> RoutingRequest: + """Translate an OpenAI-compatible chat request into a routing request.""" + + route_options = _mapping( + payload.get("route") + or payload.get("routing") + or _mapping(payload.get("metadata")).get("marama_route"), + ) + prompt, modalities = _prompt_and_modalities(payload) + tools = payload.get("tools") + response_format = _mapping(payload.get("response_format")) + task_type = str(route_options.get("task_type") or _infer_task_type(prompt, modalities)) + + return RoutingRequest.from_payload( + { + "prompt": prompt, + "task_type": task_type, + "modalities": sorted(modalities), + "jurisdiction": route_options.get("jurisdiction", "NZ"), + "data_sensitivity": route_options.get("data_sensitivity", "internal"), + "min_context_tokens": route_options.get("min_context_tokens", 4096), + "requires_local": route_options.get("requires_local", True), + "requires_tools": bool(tools) or bool(route_options.get("requires_tools")), + "requires_json": _requires_json(response_format, route_options), + "license_allowlist": route_options.get("license_allowlist", ()), + "max_fallbacks": route_options.get("max_fallbacks", 3), + "metadata": { + "requested_model": payload.get("model", "auto"), + "source_protocol": "openai_chat_completions", + }, + }, + ) + + +def build_models_response(models: tuple[ModelEndpoint, ...]) -> dict[str, Any]: + """Return an OpenAI-compatible `/v1/models` listing.""" + + return { + "object": "list", + "data": [ + { + "id": model.model_id, + "object": "model", + "created": 0, + "owned_by": model.repo_id.split("/", maxsplit=1)[0], + "metadata": { + "repo_id": model.repo_id, + "runtime": model.runtime, + "modalities": list(model.modalities), + "context_tokens": model.context_tokens, + "residency": list(model.residency), + "sovereignty_tier": model.sovereignty_tier, + "supports_tools": model.supports_tools, + "supports_json": model.supports_json, + "tags": list(model.tags), + }, + } + for model in models + ], + } + + +def build_chat_route_response( + payload: dict[str, Any], + decision: RouteDecision, +) -> dict[str, Any]: + """Return a dry-run chat response with route metadata and no generated text.""" + + selected = decision.selected_model + created = int(time.time()) + request_hash = hashlib.sha256( + json.dumps(payload, sort_keys=True, default=str).encode("utf-8"), + ).hexdigest() + model_id = selected.model_id if selected is not None else str(payload.get("model", "")) + + return { + "id": f"marama-route-{request_hash[:16]}", + "object": "chat.completion", + "created": created, + "model": model_id, + "choices": [ + { + "index": 0, + "finish_reason": "route_only", + "message": { + "role": "assistant", + "content": "", + }, + }, + ], + "usage": { + "prompt_tokens": 0, + "completion_tokens": 0, + "total_tokens": 0, + }, + "marama_route": { + "dry_run": True, + "selected_model": selected.to_dict() if selected is not None else None, + "fallback_models": [model.to_dict() for model in decision.fallback_models], + "rejected_count": len(decision.rejected), + "reasons": list(decision.reasons), + "scores": dict(decision.scores), + "request_hash": request_hash, + }, + } + + +def route_chat_payload( + payload: dict[str, Any], + models: tuple[ModelEndpoint, ...], +) -> dict[str, Any]: + request = routing_request_from_chat_payload(payload) + decision = SovereignModelRouter(models).route(request) + return { + "routing_request": request.to_dict(), + "route_decision": decision.to_dict(), + "chat_completion_response": build_chat_route_response(payload, decision), + } + + +def _mapping(value: object) -> dict[str, Any]: + return dict(value) if isinstance(value, dict) else {} + + +def _requires_json( + response_format: dict[str, Any], + route_options: dict[str, Any], +) -> bool: + if bool(route_options.get("requires_json")): + return True + response_type = str(response_format.get("type", "")).lower() + return response_type in {"json_object", "json_schema"} + + +def _prompt_and_modalities(payload: dict[str, Any]) -> tuple[str, set[str]]: + modalities = {"text"} + pieces: list[str] = [] + messages = payload.get("messages") + if isinstance(messages, list): + for message in messages: + if not isinstance(message, dict): + continue + content = message.get("content") + text, content_modalities = _content_text_and_modalities(content) + pieces.append(text) + modalities.update(content_modalities) + elif isinstance(payload.get("prompt"), str): + pieces.append(str(payload["prompt"])) + return "\n".join(piece for piece in pieces if piece), modalities + + +def _content_text_and_modalities(content: object) -> tuple[str, set[str]]: + if isinstance(content, str): + return content, {"text"} + if not isinstance(content, list): + return "", {"text"} + + pieces: list[str] = [] + modalities = {"text"} + for part in content: + if not isinstance(part, dict): + continue + part_type = str(part.get("type", "")).lower() + if part_type in {"text", "input_text"}: + pieces.append(str(part.get("text", ""))) + elif part_type in {"image", "image_url", "input_image"}: + modalities.add("image") + elif part_type in {"audio", "input_audio"}: + modalities.add("audio") + return "\n".join(piece for piece in pieces if piece), modalities + + +def _infer_task_type(prompt: str, modalities: set[str]) -> str: + prompt_lower = prompt.lower() + if "image" in modalities or "vision" in modalities: + return "multimodal" + code_markers = ( + "refactor", + "diff", + "unit test", + "python", + "typescript", + "javascript", + "repository", + "function", + "class ", + "stack trace", + ) + if any(marker in prompt_lower for marker in code_markers): + return "code" + if "reason" in prompt_lower or "prove" in prompt_lower: + return "reasoning" + return "general" diff --git a/marama_route/integrations/opencode-compatible-provider.md b/marama_route/integrations/opencode-compatible-provider.md index 9877da8213a7f0b489aa9fe086d8d7f593323893..06ca743666af12417a425086c987c4c952fb207a 100644 --- a/marama_route/integrations/opencode-compatible-provider.md +++ b/marama_route/integrations/opencode-compatible-provider.md @@ -1,96 +1,96 @@ -# OpenCode-Compatible Provider Integration - -## Goal - -Make AbteeX SovereignCode usable from OpenCode and similar coding agents without -requiring those tools to understand Data Capsules directly. - -The integration shape is: - -```text -OpenCode - -> OpenAI-compatible provider config - -> MaramaRoute gateway `/v1` - -> SovereignCode policy and tool broker - -> LumynaX model runtime -``` - -## Current Compatibility Target - -OpenCode supports custom OpenAI-compatible providers through -`@ai-sdk/openai-compatible` and a provider `baseURL`. OpenRouter exposes an -OpenAI-like chat endpoint at `/api/v1/chat/completions`, with normalized request -and response payloads. MaramaRoute should therefore expose: - -- `GET /v1/models` -- `POST /v1/chat/completions` -- `POST /v1/route` -- `GET /v1/route/{decision_id}` - -References checked on 2026-05-17: - -- https://opencode.ai/docs/providers -- https://openrouter.ai/docs/api-reference/overview/ -- https://openrouter.ai/docs/api-reference/chat-completion - -## OpenCode Provider Config - -Use `examples/opencode.marama-route.json` as the project-local provider file. - -The important fields are: - -| Field | Value | -| --- | --- | -| `provider.abteex-marama.npm` | `@ai-sdk/openai-compatible` | -| `provider.abteex-marama.options.baseURL` | Local or hosted MaramaRoute `/v1` URL | -| `provider.abteex-marama.options.apiKey` | Environment backed key | -| `provider.abteex-marama.models` | LumynaX model aliases exposed by MaramaRoute | - -## SovereignCode Responsibilities - -OpenCode sends a normal chat request. SovereignCode and MaramaRoute add: - -- capsule resolution from workspace policy files -- purpose and personal-detail checks before prompt assembly -- model routing based on residency, modality, task, and sensitivity -- visible approval gates before file writes, shell commands, network export, or commit -- audit records for policy decisions and route decisions - -## Workspace Files - -A governed workspace should carry: - -```text -.sovereigncode/ - capsule.json - tenant-policy.yaml - approvals/ - audit/ -opencode.json -``` - -The agent can start with `capsule.json` and `opencode.json`. The full tool -broker can add approvals and audit persistence in the next build stage. - -## Minimum Viable Flow - -1. User opens a project in OpenCode. -2. OpenCode uses the `abteex-marama` provider. -3. MaramaRoute dry-runs the chat payload and selects a LumynaX model. -4. SovereignCode checks the workspace Data Capsule before exposing context. -5. The coding agent proposes a plan. -6. File writes require a visible diff and an audit record. -7. Shell, network, commit, and publish actions require explicit approval. - -## Similar Clients - -Any client that can point at an OpenAI-compatible endpoint should use the same -gateway: - -| Client type | Expected integration | -| --- | --- | -| OpenCode | `opencode.json` custom provider | -| Continue-style IDE assistant | OpenAI-compatible base URL and model ids | -| Aider-style terminal assistant | OpenAI-compatible base URL and key | -| Internal agent runner | Direct `/v1/route` and `/v1/chat/completions` calls | -| Browser console | Same API behind tenant auth | +# OpenCode-Compatible Provider Integration + +## Goal + +Make AbteeX SovereignCode usable from OpenCode and similar coding agents without +requiring those tools to understand Data Capsules directly. + +The integration shape is: + +```text +OpenCode + -> OpenAI-compatible provider config + -> MaramaRoute gateway `/v1` + -> SovereignCode policy and tool broker + -> LumynaX model runtime +``` + +## Current Compatibility Target + +OpenCode supports custom OpenAI-compatible providers through +`@ai-sdk/openai-compatible` and a provider `baseURL`. OpenRouter exposes an +OpenAI-like chat endpoint at `/api/v1/chat/completions`, with normalized request +and response payloads. MaramaRoute should therefore expose: + +- `GET /v1/models` +- `POST /v1/chat/completions` +- `POST /v1/route` +- `GET /v1/route/{decision_id}` + +References checked on 2026-05-17: + +- https://opencode.ai/docs/providers +- https://openrouter.ai/docs/api-reference/overview/ +- https://openrouter.ai/docs/api-reference/chat-completion + +## OpenCode Provider Config + +Use `examples/opencode.marama-route.json` as the project-local provider file. + +The important fields are: + +| Field | Value | +| --- | --- | +| `provider.abteex-marama.npm` | `@ai-sdk/openai-compatible` | +| `provider.abteex-marama.options.baseURL` | Local or hosted MaramaRoute `/v1` URL | +| `provider.abteex-marama.options.apiKey` | Environment backed key | +| `provider.abteex-marama.models` | LumynaX model aliases exposed by MaramaRoute | + +## SovereignCode Responsibilities + +OpenCode sends a normal chat request. SovereignCode and MaramaRoute add: + +- capsule resolution from workspace policy files +- purpose and personal-detail checks before prompt assembly +- model routing based on residency, modality, task, and sensitivity +- visible approval gates before file writes, shell commands, network export, or commit +- audit records for policy decisions and route decisions + +## Workspace Files + +A governed workspace should carry: + +```text +.sovereigncode/ + capsule.json + tenant-policy.yaml + approvals/ + audit/ +opencode.json +``` + +The agent can start with `capsule.json` and `opencode.json`. The full tool +broker can add approvals and audit persistence in the next build stage. + +## Minimum Viable Flow + +1. User opens a project in OpenCode. +2. OpenCode uses the `abteex-marama` provider. +3. MaramaRoute dry-runs the chat payload and selects a LumynaX model. +4. SovereignCode checks the workspace Data Capsule before exposing context. +5. The coding agent proposes a plan. +6. File writes require a visible diff and an audit record. +7. Shell, network, commit, and publish actions require explicit approval. + +## Similar Clients + +Any client that can point at an OpenAI-compatible endpoint should use the same +gateway: + +| Client type | Expected integration | +| --- | --- | +| OpenCode | `opencode.json` custom provider | +| Continue-style IDE assistant | OpenAI-compatible base URL and model ids | +| Aider-style terminal assistant | OpenAI-compatible base URL and key | +| Internal agent runner | Direct `/v1/route` and `/v1/chat/completions` calls | +| Browser console | Same API behind tenant auth | diff --git a/marama_route/integrations/opencode-provider.json b/marama_route/integrations/opencode-provider.json index 02b278a31d185e8e620ccbdc65576fb437424288..05c32a5c558c8aaaff9a8dac194e099da29e7391 100644 --- a/marama_route/integrations/opencode-provider.json +++ b/marama_route/integrations/opencode-provider.json @@ -1,28 +1,28 @@ -{ - "$schema": "https://opencode.ai/config.json", - "provider": { - "abteex-marama": { - "npm": "@ai-sdk/openai-compatible", - "name": "AbteeX MaramaRoute", - "options": { - "baseURL": "http://127.0.0.1:8787/v1", - "apiKey": "{env:ABTEEX_MARAMA_API_KEY}", - "headers": { - "X-AbteeX-Route-Jurisdiction": "NZ", - "X-AbteeX-Route-Sensitivity": "restricted" - } - }, - "models": { - "lumynax/auto": { - "name": "LumynaX Auto" - }, - "lumynax/code": { - "name": "LumynaX Code" - }, - "lumynax/local": { - "name": "LumynaX Local" - } - } - } - } -} +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "abteex-marama": { + "npm": "@ai-sdk/openai-compatible", + "name": "AbteeX MaramaRoute", + "options": { + "baseURL": "http://127.0.0.1:8787/v1", + "apiKey": "{env:ABTEEX_MARAMA_API_KEY}", + "headers": { + "X-AbteeX-Route-Jurisdiction": "NZ", + "X-AbteeX-Route-Sensitivity": "restricted" + } + }, + "models": { + "lumynax/auto": { + "name": "LumynaX Auto" + }, + "lumynax/code": { + "name": "LumynaX Code" + }, + "lumynax/local": { + "name": "LumynaX Local" + } + } + } + } +} diff --git a/marama_route/platform.py b/marama_route/platform.py index aabaa159c28681682fa993eaad8cfd0b3378f820..768cef655c353b6fa1cb308e691b2b7c16349972 100644 --- a/marama_route/platform.py +++ b/marama_route/platform.py @@ -1,359 +1,359 @@ -from __future__ import annotations - -import hashlib -import json -from collections import Counter -from typing import Any - -from .gateway import build_models_response, route_chat_payload -from .registry import ModelEndpoint, RoutingRequest -from .router import SovereignModelRouter - -DEFAULT_ROUTE_SCENARIOS: tuple[dict[str, Any], ...] = ( - { - "name": "Restricted NZ code", - "prompt": "Refactor a private New Zealand Python service and return a JSON diff plan.", - "task_type": "code", - "modalities": ["text"], - "jurisdiction": "NZ", - "data_sensitivity": "restricted", - "min_context_tokens": 4096, - "requires_local": True, - "requires_json": True, - "requires_tools": False, - "max_fallbacks": 3, - }, - { - "name": "Personal memory", - "prompt": "Summarise local operator preferences without exposing raw personal notes.", - "task_type": "general", - "modalities": ["text"], - "jurisdiction": "NZ", - "data_sensitivity": "personal", - "min_context_tokens": 4096, - "requires_local": True, - "requires_json": False, - "requires_tools": False, - "max_fallbacks": 3, - }, - { - "name": "Vision document", - "prompt": "Read a scanned table image and extract structured rows.", - "task_type": "multimodal", - "modalities": ["text", "image"], - "jurisdiction": "NZ", - "data_sensitivity": "internal", - "min_context_tokens": 4096, - "requires_local": False, - "requires_json": True, - "requires_tools": False, - "max_fallbacks": 3, - }, - { - "name": "Reasoning brief", - "prompt": "Reason through a procurement risk register and produce a concise decision memo.", - "task_type": "reasoning", - "modalities": ["text"], - "jurisdiction": "NZ", - "data_sensitivity": "internal", - "min_context_tokens": 8192, - "requires_local": True, - "requires_json": False, - "requires_tools": False, - "max_fallbacks": 3, - }, -) - - -def build_registry_analytics(models: tuple[ModelEndpoint, ...]) -> dict[str, Any]: - runtimes = Counter(model.runtime for model in models) - families = Counter(model.family for model in models) - modalities = Counter(modality for model in models for modality in model.modalities) - tiers = Counter(str(model.sovereignty_tier) for model in models) - resident_nz = sum(1 for model in models if "NZ" in model.residency) - json_ready = sum(1 for model in models if model.supports_json) - tool_ready = sum(1 for model in models if model.supports_tools) - local_runtimes = sum(1 for model in models if _is_local_runtime(model.runtime)) - context_values = [model.context_tokens for model in models] - return { - "model_count": len(models), - "resident_nz": resident_nz, - "local_runtimes": local_runtimes, - "json_ready": json_ready, - "tool_ready": tool_ready, - "max_context_tokens": max(context_values) if context_values else 0, - "avg_context_tokens": round(sum(context_values) / len(context_values), 2) if context_values else 0, - "runtimes": dict(sorted(runtimes.items())), - "families": dict(sorted(families.items())), - "modalities": dict(sorted(modalities.items())), - "sovereignty_tiers": dict(sorted(tiers.items())), - "top_models": [model_summary(model) for model in _top_models(models, limit=8)], - } - - -def catalog_models( - models: tuple[ModelEndpoint, ...], - filters: dict[str, Any] | None = None, -) -> dict[str, Any]: - filters = filters or {} - search = str(filters.get("search") or "").strip().lower() - runtime = str(filters.get("runtime") or "").strip().lower() - family = str(filters.get("family") or "").strip().lower() - modality = str(filters.get("modality") or "").strip().lower() - task_type = str(filters.get("task_type") or "").strip().lower() - jurisdiction = str(filters.get("jurisdiction") or "").strip().upper() - min_context = int(filters.get("min_context_tokens") or 0) - limit = int(filters.get("limit") or 50) - requires_json = bool(filters.get("requires_json", False)) - requires_tools = bool(filters.get("requires_tools", False)) - requires_local = bool(filters.get("requires_local", False)) - - filtered: list[ModelEndpoint] = [] - for model in models: - haystack = " ".join( - ( - model.model_id, - model.repo_id, - model.family, - model.runtime, - " ".join(model.tags), - ), - ).lower() - if search and search not in haystack: - continue - if runtime and model.runtime.lower() != runtime: - continue - if family and model.family.lower() != family: - continue - if modality and modality not in {item.lower() for item in model.modalities}: - continue - if task_type and not _matches_task(model, task_type): - continue - if jurisdiction and jurisdiction not in model.residency: - continue - if min_context and model.context_tokens < min_context: - continue - if requires_json and not model.supports_json: - continue - if requires_tools and not model.supports_tools: - continue - if requires_local and not _is_local_runtime(model.runtime): - continue - filtered.append(model) - - ranked = sorted(filtered, key=_catalog_sort_key, reverse=True) - return { - "ok": True, - "count": len(ranked), - "filters": filters, - "models": [model_summary(model) for model in ranked[:limit]], - } - - -def compare_models( - models: tuple[ModelEndpoint, ...], - model_ids: list[str], - request_payload: dict[str, Any] | None = None, -) -> dict[str, Any]: - index = {model.model_id: model for model in models} - selected = [index[model_id] for model_id in model_ids if model_id in index] - missing = [model_id for model_id in model_ids if model_id not in index] - request = RoutingRequest.from_payload(request_payload or DEFAULT_ROUTE_SCENARIOS[0]) - route_scores = SovereignModelRouter(tuple(selected)).route(request).scores if selected else {} - rows = [] - for model in selected: - row = model_summary(model) - row["route_score"] = route_scores.get(model.model_id) - row["operator_score"] = _operator_score(model) - rows.append(row) - winner = max(rows, key=lambda item: (item.get("route_score") or -1, item["operator_score"]), default=None) - return { - "ok": bool(rows), - "missing": missing, - "request": request.to_dict(), - "winner": winner, - "models": rows, - } - - -def route_scenario_matrix( - models: tuple[ModelEndpoint, ...], - scenarios: list[dict[str, Any]] | None = None, -) -> dict[str, Any]: - router = SovereignModelRouter(models) - rows = [] - for scenario in scenarios or [dict(item) for item in DEFAULT_ROUTE_SCENARIOS]: - request = RoutingRequest.from_payload(scenario) - decision = router.route(request) - selected = decision.selected_model - rows.append( - { - "name": scenario.get("name", request.task_type), - "ok": selected is not None, - "task_type": request.task_type, - "sensitivity": request.data_sensitivity, - "selected_model": selected.model_id if selected else None, - "runtime": selected.runtime if selected else None, - "fallback_count": len(decision.fallback_models), - "rejected_count": len(decision.rejected), - "reasons": list(decision.reasons), - }, - ) - return {"ok": all(row["ok"] for row in rows), "scenarios": rows} - - -def build_opencode_provider_config( - models: tuple[ModelEndpoint, ...], - *, - base_url: str = "http://127.0.0.1:8787/v1", - provider_id: str = "abteex-marama", -) -> dict[str, Any]: - route = SovereignModelRouter(models).route(RoutingRequest.from_payload(DEFAULT_ROUTE_SCENARIOS[0])) - default_model = route.selected_model or (_top_models(models, limit=1)[0] if models else None) - catalog = _top_models(models, limit=14) - model_entries = { - model.model_id: { - "name": model.model_id, - "context": model.context_tokens, - "modalities": list(model.modalities), - "residency": list(model.residency), - "runtime": model.runtime, - } - for model in catalog - } - return { - "$schema": "https://opencode.ai/config.json", - "provider": { - provider_id: { - "name": "AbteeX MaramaRoute", - "npm": "@ai-sdk/openai-compatible", - "options": { - "baseURL": base_url, - "apiKey": "${ABTEEX_MARAMA_API_KEY:-local-dev}", - }, - "models": model_entries, - }, - }, - "model": f"{provider_id}/{default_model.model_id}" if default_model else "", - "small_model": f"{provider_id}/{catalog[-1].model_id}" if catalog else "", - } - - -def route_receipt(payload: dict[str, Any], route_result: dict[str, Any]) -> dict[str, Any]: - selected = route_result.get("route_decision", {}).get("selected_model") - receipt_payload = { - "request": payload, - "selected_model_id": selected.get("model_id") if isinstance(selected, dict) else None, - "rejected_count": len(route_result.get("route_decision", {}).get("rejected", [])), - } - digest = hashlib.sha256( - json.dumps(receipt_payload, sort_keys=True, default=str).encode("utf-8"), - ).hexdigest() - return { - "receipt_id": f"marama-{digest[:16]}", - "request_hash": digest, - "selected_model": receipt_payload["selected_model_id"], - "prompt_retention": "not_stored_by_default", - "audit_fields": [ - "request_hash", - "selected_model", - "fallback_models", - "rejected_count", - "residency", - "runtime", - ], - } - - -def route_or_chat_payload(payload: dict[str, Any], models: tuple[ModelEndpoint, ...]) -> dict[str, Any]: - if "messages" in payload: - result = route_chat_payload(payload, models) - selected = result["route_decision"]["selected_model"] - result = {"ok": selected is not None, "mode": "openai_chat_dry_run", **result} - else: - request = RoutingRequest.from_payload(payload) - decision = SovereignModelRouter(models).route(request) - result = { - "ok": decision.selected_model is not None, - "mode": "route", - "routing_request": request.to_dict(), - "route_decision": decision.to_dict(), - } - result["receipt"] = route_receipt(payload, result) - return result - - -def build_models_api(models: tuple[ModelEndpoint, ...]) -> dict[str, Any]: - response = build_models_response(models) - response["analytics"] = build_registry_analytics(models) - return response - - -def model_summary(model: ModelEndpoint) -> dict[str, Any]: - return { - "model_id": model.model_id, - "repo_id": model.repo_id, - "family": model.family, - "runtime": model.runtime, - "modalities": list(model.modalities), - "context_tokens": model.context_tokens, - "residency": list(model.residency), - "license_id": model.license_id, - "active_params_b": model.active_params_b, - "total_params_b": model.total_params_b, - "quality_rank": model.quality_rank, - "cost_rank": model.cost_rank, - "sovereignty_tier": model.sovereignty_tier, - "supports_json": model.supports_json, - "supports_tools": model.supports_tools, - "tags": list(model.tags), - "operator_score": _operator_score(model), - } - - -def scenario_presets() -> list[dict[str, Any]]: - return [dict(item) for item in DEFAULT_ROUTE_SCENARIOS] - - -def _top_models(models: tuple[ModelEndpoint, ...], *, limit: int) -> list[ModelEndpoint]: - return sorted(models, key=_catalog_sort_key, reverse=True)[:limit] - - -def _catalog_sort_key(model: ModelEndpoint) -> tuple[float, int, str]: - return (_operator_score(model), model.context_tokens, model.model_id) - - -def _operator_score(model: ModelEndpoint) -> float: - score = 0.0 - if "NZ" in model.residency: - score += 25 - if _is_local_runtime(model.runtime): - score += 15 - score += model.sovereignty_tier * 10 - score += max(0, 10 - model.quality_rank) * 3 - score -= model.cost_rank - if model.supports_json: - score += 5 - if model.supports_tools: - score += 5 - if model.context_tokens >= 32768: - score += 6 - elif model.context_tokens >= 8192: - score += 3 - return round(score, 2) - - -def _matches_task(model: ModelEndpoint, task_type: str) -> bool: - tags = set(model.tags) - if task_type in tags or task_type in model.family.lower() or task_type in model.model_id.lower(): - return True - if task_type == "code": - return "coder" in tags or "coder" in model.model_id.lower() - if task_type == "multimodal": - return "image" in model.modalities or "multimodal" in tags - return False - - -def _is_local_runtime(runtime: str) -> bool: - value = runtime.lower() - return value in {"llama_cpp", "gguf", "transformers", "sentence_transformers"} or "local" in value +from __future__ import annotations + +import hashlib +import json +from collections import Counter +from typing import Any + +from .gateway import build_models_response, route_chat_payload +from .registry import ModelEndpoint, RoutingRequest +from .router import SovereignModelRouter + +DEFAULT_ROUTE_SCENARIOS: tuple[dict[str, Any], ...] = ( + { + "name": "Restricted NZ code", + "prompt": "Refactor a private New Zealand Python service and return a JSON diff plan.", + "task_type": "code", + "modalities": ["text"], + "jurisdiction": "NZ", + "data_sensitivity": "restricted", + "min_context_tokens": 4096, + "requires_local": True, + "requires_json": True, + "requires_tools": False, + "max_fallbacks": 3, + }, + { + "name": "Personal memory", + "prompt": "Summarise local operator preferences without exposing raw personal notes.", + "task_type": "general", + "modalities": ["text"], + "jurisdiction": "NZ", + "data_sensitivity": "personal", + "min_context_tokens": 4096, + "requires_local": True, + "requires_json": False, + "requires_tools": False, + "max_fallbacks": 3, + }, + { + "name": "Vision document", + "prompt": "Read a scanned table image and extract structured rows.", + "task_type": "multimodal", + "modalities": ["text", "image"], + "jurisdiction": "NZ", + "data_sensitivity": "internal", + "min_context_tokens": 4096, + "requires_local": False, + "requires_json": True, + "requires_tools": False, + "max_fallbacks": 3, + }, + { + "name": "Reasoning brief", + "prompt": "Reason through a procurement risk register and produce a concise decision memo.", + "task_type": "reasoning", + "modalities": ["text"], + "jurisdiction": "NZ", + "data_sensitivity": "internal", + "min_context_tokens": 8192, + "requires_local": True, + "requires_json": False, + "requires_tools": False, + "max_fallbacks": 3, + }, +) + + +def build_registry_analytics(models: tuple[ModelEndpoint, ...]) -> dict[str, Any]: + runtimes = Counter(model.runtime for model in models) + families = Counter(model.family for model in models) + modalities = Counter(modality for model in models for modality in model.modalities) + tiers = Counter(str(model.sovereignty_tier) for model in models) + resident_nz = sum(1 for model in models if "NZ" in model.residency) + json_ready = sum(1 for model in models if model.supports_json) + tool_ready = sum(1 for model in models if model.supports_tools) + local_runtimes = sum(1 for model in models if _is_local_runtime(model.runtime)) + context_values = [model.context_tokens for model in models] + return { + "model_count": len(models), + "resident_nz": resident_nz, + "local_runtimes": local_runtimes, + "json_ready": json_ready, + "tool_ready": tool_ready, + "max_context_tokens": max(context_values) if context_values else 0, + "avg_context_tokens": round(sum(context_values) / len(context_values), 2) if context_values else 0, + "runtimes": dict(sorted(runtimes.items())), + "families": dict(sorted(families.items())), + "modalities": dict(sorted(modalities.items())), + "sovereignty_tiers": dict(sorted(tiers.items())), + "top_models": [model_summary(model) for model in _top_models(models, limit=8)], + } + + +def catalog_models( + models: tuple[ModelEndpoint, ...], + filters: dict[str, Any] | None = None, +) -> dict[str, Any]: + filters = filters or {} + search = str(filters.get("search") or "").strip().lower() + runtime = str(filters.get("runtime") or "").strip().lower() + family = str(filters.get("family") or "").strip().lower() + modality = str(filters.get("modality") or "").strip().lower() + task_type = str(filters.get("task_type") or "").strip().lower() + jurisdiction = str(filters.get("jurisdiction") or "").strip().upper() + min_context = int(filters.get("min_context_tokens") or 0) + limit = int(filters.get("limit") or 50) + requires_json = bool(filters.get("requires_json", False)) + requires_tools = bool(filters.get("requires_tools", False)) + requires_local = bool(filters.get("requires_local", False)) + + filtered: list[ModelEndpoint] = [] + for model in models: + haystack = " ".join( + ( + model.model_id, + model.repo_id, + model.family, + model.runtime, + " ".join(model.tags), + ), + ).lower() + if search and search not in haystack: + continue + if runtime and model.runtime.lower() != runtime: + continue + if family and model.family.lower() != family: + continue + if modality and modality not in {item.lower() for item in model.modalities}: + continue + if task_type and not _matches_task(model, task_type): + continue + if jurisdiction and jurisdiction not in model.residency: + continue + if min_context and model.context_tokens < min_context: + continue + if requires_json and not model.supports_json: + continue + if requires_tools and not model.supports_tools: + continue + if requires_local and not _is_local_runtime(model.runtime): + continue + filtered.append(model) + + ranked = sorted(filtered, key=_catalog_sort_key, reverse=True) + return { + "ok": True, + "count": len(ranked), + "filters": filters, + "models": [model_summary(model) for model in ranked[:limit]], + } + + +def compare_models( + models: tuple[ModelEndpoint, ...], + model_ids: list[str], + request_payload: dict[str, Any] | None = None, +) -> dict[str, Any]: + index = {model.model_id: model for model in models} + selected = [index[model_id] for model_id in model_ids if model_id in index] + missing = [model_id for model_id in model_ids if model_id not in index] + request = RoutingRequest.from_payload(request_payload or DEFAULT_ROUTE_SCENARIOS[0]) + route_scores = SovereignModelRouter(tuple(selected)).route(request).scores if selected else {} + rows = [] + for model in selected: + row = model_summary(model) + row["route_score"] = route_scores.get(model.model_id) + row["operator_score"] = _operator_score(model) + rows.append(row) + winner = max(rows, key=lambda item: (item.get("route_score") or -1, item["operator_score"]), default=None) + return { + "ok": bool(rows), + "missing": missing, + "request": request.to_dict(), + "winner": winner, + "models": rows, + } + + +def route_scenario_matrix( + models: tuple[ModelEndpoint, ...], + scenarios: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + router = SovereignModelRouter(models) + rows = [] + for scenario in scenarios or [dict(item) for item in DEFAULT_ROUTE_SCENARIOS]: + request = RoutingRequest.from_payload(scenario) + decision = router.route(request) + selected = decision.selected_model + rows.append( + { + "name": scenario.get("name", request.task_type), + "ok": selected is not None, + "task_type": request.task_type, + "sensitivity": request.data_sensitivity, + "selected_model": selected.model_id if selected else None, + "runtime": selected.runtime if selected else None, + "fallback_count": len(decision.fallback_models), + "rejected_count": len(decision.rejected), + "reasons": list(decision.reasons), + }, + ) + return {"ok": all(row["ok"] for row in rows), "scenarios": rows} + + +def build_opencode_provider_config( + models: tuple[ModelEndpoint, ...], + *, + base_url: str = "http://127.0.0.1:8787/v1", + provider_id: str = "abteex-marama", +) -> dict[str, Any]: + route = SovereignModelRouter(models).route(RoutingRequest.from_payload(DEFAULT_ROUTE_SCENARIOS[0])) + default_model = route.selected_model or (_top_models(models, limit=1)[0] if models else None) + catalog = _top_models(models, limit=14) + model_entries = { + model.model_id: { + "name": model.model_id, + "context": model.context_tokens, + "modalities": list(model.modalities), + "residency": list(model.residency), + "runtime": model.runtime, + } + for model in catalog + } + return { + "$schema": "https://opencode.ai/config.json", + "provider": { + provider_id: { + "name": "AbteeX MaramaRoute", + "npm": "@ai-sdk/openai-compatible", + "options": { + "baseURL": base_url, + "apiKey": "${ABTEEX_MARAMA_API_KEY:-local-dev}", + }, + "models": model_entries, + }, + }, + "model": f"{provider_id}/{default_model.model_id}" if default_model else "", + "small_model": f"{provider_id}/{catalog[-1].model_id}" if catalog else "", + } + + +def route_receipt(payload: dict[str, Any], route_result: dict[str, Any]) -> dict[str, Any]: + selected = route_result.get("route_decision", {}).get("selected_model") + receipt_payload = { + "request": payload, + "selected_model_id": selected.get("model_id") if isinstance(selected, dict) else None, + "rejected_count": len(route_result.get("route_decision", {}).get("rejected", [])), + } + digest = hashlib.sha256( + json.dumps(receipt_payload, sort_keys=True, default=str).encode("utf-8"), + ).hexdigest() + return { + "receipt_id": f"marama-{digest[:16]}", + "request_hash": digest, + "selected_model": receipt_payload["selected_model_id"], + "prompt_retention": "not_stored_by_default", + "audit_fields": [ + "request_hash", + "selected_model", + "fallback_models", + "rejected_count", + "residency", + "runtime", + ], + } + + +def route_or_chat_payload(payload: dict[str, Any], models: tuple[ModelEndpoint, ...]) -> dict[str, Any]: + if "messages" in payload: + result = route_chat_payload(payload, models) + selected = result["route_decision"]["selected_model"] + result = {"ok": selected is not None, "mode": "openai_chat_dry_run", **result} + else: + request = RoutingRequest.from_payload(payload) + decision = SovereignModelRouter(models).route(request) + result = { + "ok": decision.selected_model is not None, + "mode": "route", + "routing_request": request.to_dict(), + "route_decision": decision.to_dict(), + } + result["receipt"] = route_receipt(payload, result) + return result + + +def build_models_api(models: tuple[ModelEndpoint, ...]) -> dict[str, Any]: + response = build_models_response(models) + response["analytics"] = build_registry_analytics(models) + return response + + +def model_summary(model: ModelEndpoint) -> dict[str, Any]: + return { + "model_id": model.model_id, + "repo_id": model.repo_id, + "family": model.family, + "runtime": model.runtime, + "modalities": list(model.modalities), + "context_tokens": model.context_tokens, + "residency": list(model.residency), + "license_id": model.license_id, + "active_params_b": model.active_params_b, + "total_params_b": model.total_params_b, + "quality_rank": model.quality_rank, + "cost_rank": model.cost_rank, + "sovereignty_tier": model.sovereignty_tier, + "supports_json": model.supports_json, + "supports_tools": model.supports_tools, + "tags": list(model.tags), + "operator_score": _operator_score(model), + } + + +def scenario_presets() -> list[dict[str, Any]]: + return [dict(item) for item in DEFAULT_ROUTE_SCENARIOS] + + +def _top_models(models: tuple[ModelEndpoint, ...], *, limit: int) -> list[ModelEndpoint]: + return sorted(models, key=_catalog_sort_key, reverse=True)[:limit] + + +def _catalog_sort_key(model: ModelEndpoint) -> tuple[float, int, str]: + return (_operator_score(model), model.context_tokens, model.model_id) + + +def _operator_score(model: ModelEndpoint) -> float: + score = 0.0 + if "NZ" in model.residency: + score += 25 + if _is_local_runtime(model.runtime): + score += 15 + score += model.sovereignty_tier * 10 + score += max(0, 10 - model.quality_rank) * 3 + score -= model.cost_rank + if model.supports_json: + score += 5 + if model.supports_tools: + score += 5 + if model.context_tokens >= 32768: + score += 6 + elif model.context_tokens >= 8192: + score += 3 + return round(score, 2) + + +def _matches_task(model: ModelEndpoint, task_type: str) -> bool: + tags = set(model.tags) + if task_type in tags or task_type in model.family.lower() or task_type in model.model_id.lower(): + return True + if task_type == "code": + return "coder" in tags or "coder" in model.model_id.lower() + if task_type == "multimodal": + return "image" in model.modalities or "multimodal" in tags + return False + + +def _is_local_runtime(runtime: str) -> bool: + value = runtime.lower() + return value in {"llama_cpp", "gguf", "transformers", "sentence_transformers"} or "local" in value diff --git a/marama_route/registry.py b/marama_route/registry.py index 91f39bed896636b27ab1ee93d095caa6d3c815f8..dc51f50629157d569420f2b9305ea7e496d9510f 100644 --- a/marama_route/registry.py +++ b/marama_route/registry.py @@ -1,150 +1,150 @@ -from __future__ import annotations - -import json -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - - -def _text_tuple(value: object, *, default: tuple[str, ...] = ()) -> tuple[str, ...]: - if value in (None, ""): - return default - if isinstance(value, str): - return (value,) - if isinstance(value, (list, tuple, set)): - return tuple(str(item).strip() for item in value if str(item).strip()) - return (str(value).strip(),) - - -@dataclass(frozen=True, slots=True) -class ModelEndpoint: - model_id: str - repo_id: str - family: str - runtime: str - modalities: tuple[str, ...] = ("text",) - context_tokens: int = 4096 - jurisdiction: str = "NZ" - residency: tuple[str, ...] = ("NZ",) - license_id: str = "see_model_card" - quantization: str = "see_manifest" - primary_artifact: str = "" - active_params_b: float | None = None - total_params_b: float | None = None - quality_rank: int = 5 - cost_rank: int = 5 - sovereignty_tier: int = 2 - supports_tools: bool = False - supports_json: bool = False - tags: tuple[str, ...] = () - metadata: dict[str, Any] = field(default_factory=dict) - - @classmethod - def from_payload(cls, payload: dict[str, Any]) -> ModelEndpoint: - return cls( - model_id=str(payload.get("model_id") or payload.get("repo_id") or ""), - repo_id=str(payload.get("repo_id") or payload.get("model_id") or ""), - family=str(payload.get("family") or "lumynax"), - runtime=str(payload.get("runtime") or "llama_cpp"), - modalities=_text_tuple(payload.get("modalities"), default=("text",)), - context_tokens=int(payload.get("context_tokens") or 4096), - jurisdiction=str(payload.get("jurisdiction") or "NZ").upper(), - residency=tuple(str(item).upper() for item in _text_tuple(payload.get("residency"), default=("NZ",))), - license_id=str(payload.get("license_id") or "see_model_card"), - quantization=str(payload.get("quantization") or "see_manifest"), - primary_artifact=str(payload.get("primary_artifact") or ""), - active_params_b=( - float(payload["active_params_b"]) if payload.get("active_params_b") is not None else None - ), - total_params_b=( - float(payload["total_params_b"]) if payload.get("total_params_b") is not None else None - ), - quality_rank=int(payload.get("quality_rank") or 5), - cost_rank=int(payload.get("cost_rank") or 5), - sovereignty_tier=int(payload.get("sovereignty_tier") or 2), - supports_tools=bool(payload.get("supports_tools", False)), - supports_json=bool(payload.get("supports_json", False)), - tags=tuple(item.lower() for item in _text_tuple(payload.get("tags"))), - metadata=dict(payload.get("metadata") or {}), - ) - - def to_dict(self) -> dict[str, Any]: - return { - "model_id": self.model_id, - "repo_id": self.repo_id, - "family": self.family, - "runtime": self.runtime, - "modalities": list(self.modalities), - "context_tokens": self.context_tokens, - "jurisdiction": self.jurisdiction, - "residency": list(self.residency), - "license_id": self.license_id, - "quantization": self.quantization, - "primary_artifact": self.primary_artifact, - "active_params_b": self.active_params_b, - "total_params_b": self.total_params_b, - "quality_rank": self.quality_rank, - "cost_rank": self.cost_rank, - "sovereignty_tier": self.sovereignty_tier, - "supports_tools": self.supports_tools, - "supports_json": self.supports_json, - "tags": list(self.tags), - "metadata": dict(self.metadata), - } - - -@dataclass(frozen=True, slots=True) -class RoutingRequest: - prompt: str - task_type: str = "general" - modalities: tuple[str, ...] = ("text",) - jurisdiction: str = "NZ" - data_sensitivity: str = "internal" - min_context_tokens: int = 4096 - requires_local: bool = True - requires_tools: bool = False - requires_json: bool = False - license_allowlist: tuple[str, ...] = () - max_fallbacks: int = 3 - metadata: dict[str, Any] = field(default_factory=dict) - - @classmethod - def from_payload(cls, payload: dict[str, Any]) -> RoutingRequest: - return cls( - prompt=str(payload.get("prompt") or ""), - task_type=str(payload.get("task_type") or "general").lower(), - modalities=tuple(item.lower() for item in _text_tuple(payload.get("modalities"), default=("text",))), - jurisdiction=str(payload.get("jurisdiction") or "NZ").upper(), - data_sensitivity=str(payload.get("data_sensitivity") or "internal").lower(), - min_context_tokens=int(payload.get("min_context_tokens") or 4096), - requires_local=bool(payload.get("requires_local", True)), - requires_tools=bool(payload.get("requires_tools", False)), - requires_json=bool(payload.get("requires_json", False)), - license_allowlist=tuple(item.lower() for item in _text_tuple(payload.get("license_allowlist"))), - max_fallbacks=int(payload.get("max_fallbacks") or 3), - metadata=dict(payload.get("metadata") or {}), - ) - - def to_dict(self) -> dict[str, Any]: - return { - "prompt": self.prompt, - "task_type": self.task_type, - "modalities": list(self.modalities), - "jurisdiction": self.jurisdiction, - "data_sensitivity": self.data_sensitivity, - "min_context_tokens": self.min_context_tokens, - "requires_local": self.requires_local, - "requires_tools": self.requires_tools, - "requires_json": self.requires_json, - "license_allowlist": list(self.license_allowlist), - "max_fallbacks": self.max_fallbacks, - "metadata": dict(self.metadata), - } - - -def load_model_registry(path: Path) -> tuple[ModelEndpoint, ...]: - payload = json.loads(path.read_text(encoding="utf-8-sig")) - raw_models = payload.get("models") if isinstance(payload, dict) else payload - if not isinstance(raw_models, list): - raise ValueError(f"Expected model list in {path}") - return tuple(ModelEndpoint.from_payload(item) for item in raw_models if isinstance(item, dict)) +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + + +def _text_tuple(value: object, *, default: tuple[str, ...] = ()) -> tuple[str, ...]: + if value in (None, ""): + return default + if isinstance(value, str): + return (value,) + if isinstance(value, (list, tuple, set)): + return tuple(str(item).strip() for item in value if str(item).strip()) + return (str(value).strip(),) + + +@dataclass(frozen=True, slots=True) +class ModelEndpoint: + model_id: str + repo_id: str + family: str + runtime: str + modalities: tuple[str, ...] = ("text",) + context_tokens: int = 4096 + jurisdiction: str = "NZ" + residency: tuple[str, ...] = ("NZ",) + license_id: str = "see_model_card" + quantization: str = "see_manifest" + primary_artifact: str = "" + active_params_b: float | None = None + total_params_b: float | None = None + quality_rank: int = 5 + cost_rank: int = 5 + sovereignty_tier: int = 2 + supports_tools: bool = False + supports_json: bool = False + tags: tuple[str, ...] = () + metadata: dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_payload(cls, payload: dict[str, Any]) -> ModelEndpoint: + return cls( + model_id=str(payload.get("model_id") or payload.get("repo_id") or ""), + repo_id=str(payload.get("repo_id") or payload.get("model_id") or ""), + family=str(payload.get("family") or "lumynax"), + runtime=str(payload.get("runtime") or "llama_cpp"), + modalities=_text_tuple(payload.get("modalities"), default=("text",)), + context_tokens=int(payload.get("context_tokens") or 4096), + jurisdiction=str(payload.get("jurisdiction") or "NZ").upper(), + residency=tuple(str(item).upper() for item in _text_tuple(payload.get("residency"), default=("NZ",))), + license_id=str(payload.get("license_id") or "see_model_card"), + quantization=str(payload.get("quantization") or "see_manifest"), + primary_artifact=str(payload.get("primary_artifact") or ""), + active_params_b=( + float(payload["active_params_b"]) if payload.get("active_params_b") is not None else None + ), + total_params_b=( + float(payload["total_params_b"]) if payload.get("total_params_b") is not None else None + ), + quality_rank=int(payload.get("quality_rank") or 5), + cost_rank=int(payload.get("cost_rank") or 5), + sovereignty_tier=int(payload.get("sovereignty_tier") or 2), + supports_tools=bool(payload.get("supports_tools", False)), + supports_json=bool(payload.get("supports_json", False)), + tags=tuple(item.lower() for item in _text_tuple(payload.get("tags"))), + metadata=dict(payload.get("metadata") or {}), + ) + + def to_dict(self) -> dict[str, Any]: + return { + "model_id": self.model_id, + "repo_id": self.repo_id, + "family": self.family, + "runtime": self.runtime, + "modalities": list(self.modalities), + "context_tokens": self.context_tokens, + "jurisdiction": self.jurisdiction, + "residency": list(self.residency), + "license_id": self.license_id, + "quantization": self.quantization, + "primary_artifact": self.primary_artifact, + "active_params_b": self.active_params_b, + "total_params_b": self.total_params_b, + "quality_rank": self.quality_rank, + "cost_rank": self.cost_rank, + "sovereignty_tier": self.sovereignty_tier, + "supports_tools": self.supports_tools, + "supports_json": self.supports_json, + "tags": list(self.tags), + "metadata": dict(self.metadata), + } + + +@dataclass(frozen=True, slots=True) +class RoutingRequest: + prompt: str + task_type: str = "general" + modalities: tuple[str, ...] = ("text",) + jurisdiction: str = "NZ" + data_sensitivity: str = "internal" + min_context_tokens: int = 4096 + requires_local: bool = True + requires_tools: bool = False + requires_json: bool = False + license_allowlist: tuple[str, ...] = () + max_fallbacks: int = 3 + metadata: dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_payload(cls, payload: dict[str, Any]) -> RoutingRequest: + return cls( + prompt=str(payload.get("prompt") or ""), + task_type=str(payload.get("task_type") or "general").lower(), + modalities=tuple(item.lower() for item in _text_tuple(payload.get("modalities"), default=("text",))), + jurisdiction=str(payload.get("jurisdiction") or "NZ").upper(), + data_sensitivity=str(payload.get("data_sensitivity") or "internal").lower(), + min_context_tokens=int(payload.get("min_context_tokens") or 4096), + requires_local=bool(payload.get("requires_local", True)), + requires_tools=bool(payload.get("requires_tools", False)), + requires_json=bool(payload.get("requires_json", False)), + license_allowlist=tuple(item.lower() for item in _text_tuple(payload.get("license_allowlist"))), + max_fallbacks=int(payload.get("max_fallbacks") or 3), + metadata=dict(payload.get("metadata") or {}), + ) + + def to_dict(self) -> dict[str, Any]: + return { + "prompt": self.prompt, + "task_type": self.task_type, + "modalities": list(self.modalities), + "jurisdiction": self.jurisdiction, + "data_sensitivity": self.data_sensitivity, + "min_context_tokens": self.min_context_tokens, + "requires_local": self.requires_local, + "requires_tools": self.requires_tools, + "requires_json": self.requires_json, + "license_allowlist": list(self.license_allowlist), + "max_fallbacks": self.max_fallbacks, + "metadata": dict(self.metadata), + } + + +def load_model_registry(path: Path) -> tuple[ModelEndpoint, ...]: + payload = json.loads(path.read_text(encoding="utf-8-sig")) + raw_models = payload.get("models") if isinstance(payload, dict) else payload + if not isinstance(raw_models, list): + raise ValueError(f"Expected model list in {path}") + return tuple(ModelEndpoint.from_payload(item) for item in raw_models if isinstance(item, dict)) diff --git a/marama_route/router.py b/marama_route/router.py index 746ee4951337259136b31023aebdaa5c4a203110..da86583de9e47e1ea051301a76a0f99298efcfd1 100644 --- a/marama_route/router.py +++ b/marama_route/router.py @@ -1,113 +1,113 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import Any - -from .registry import ModelEndpoint, RoutingRequest - -HIGH_SENSITIVITY = frozenset({"personal", "restricted", "health", "iwi", "taonga"}) - - -@dataclass(frozen=True, slots=True) -class RouteDecision: - selected_model: ModelEndpoint | None - fallback_models: tuple[ModelEndpoint, ...] - rejected: tuple[dict[str, str], ...] - reasons: tuple[str, ...] - scores: dict[str, float] - - def to_dict(self) -> dict[str, Any]: - return { - "selected_model": self.selected_model.to_dict() if self.selected_model else None, - "fallback_models": [model.to_dict() for model in self.fallback_models], - "rejected": list(self.rejected), - "reasons": list(self.reasons), - "scores": dict(self.scores), - } - - -class SovereignModelRouter: - def __init__(self, models: tuple[ModelEndpoint, ...]) -> None: - self.models = models - - def route(self, request: RoutingRequest) -> RouteDecision: - accepted: list[ModelEndpoint] = [] - rejected: list[dict[str, str]] = [] - requested_modalities = set(request.modalities) - license_allowlist = set(request.license_allowlist) - - for model in self.models: - model_modalities = set(item.lower() for item in model.modalities) - if not requested_modalities.issubset(model_modalities): - rejected.append({"model_id": model.model_id, "reason": "modality_mismatch"}) - continue - if model.context_tokens < request.min_context_tokens: - rejected.append({"model_id": model.model_id, "reason": "context_too_small"}) - continue - if request.requires_tools and not model.supports_tools: - rejected.append({"model_id": model.model_id, "reason": "tools_required"}) - continue - if request.requires_json and not model.supports_json: - rejected.append({"model_id": model.model_id, "reason": "json_required"}) - continue - if license_allowlist and model.license_id.lower() not in license_allowlist: - rejected.append({"model_id": model.model_id, "reason": "license_not_allowed"}) - continue - if request.requires_local and request.jurisdiction not in model.residency: - rejected.append({"model_id": model.model_id, "reason": "residency_mismatch"}) - continue - if request.data_sensitivity in HIGH_SENSITIVITY and model.sovereignty_tier < 2: - rejected.append({"model_id": model.model_id, "reason": "sovereignty_tier_too_low"}) - continue - accepted.append(model) - - scores = {model.model_id: self._score(model, request) for model in accepted} - ranked = tuple(sorted(accepted, key=lambda model: (scores[model.model_id], model.model_id), reverse=True)) - selected = ranked[0] if ranked else None - fallbacks = ranked[1 : 1 + request.max_fallbacks] - reasons = self._reasons(selected, request) - return RouteDecision( - selected_model=selected, - fallback_models=fallbacks, - rejected=tuple(rejected), - reasons=reasons, - scores=scores, - ) - - def _score(self, model: ModelEndpoint, request: RoutingRequest) -> float: - score = 0.0 - tags = set(model.tags) - prompt_lower = request.prompt.lower() - if request.jurisdiction in model.residency: - score += 8.0 - if request.task_type in tags or request.task_type in model.family.lower(): - score += 7.0 - if request.task_type == "code" and ("coder" in model.model_id or "coder" in tags): - score += 10.0 - if request.task_type == "reasoning" and ("reasoning" in model.model_id or "reasoning" in tags): - score += 9.0 - if "iwi" in prompt_lower or "data sovereignty" in prompt_lower: - score += 3.0 * model.sovereignty_tier - if "gguf" in model.runtime.lower() or model.runtime == "llama_cpp": - score += 2.5 - if model.supports_json and request.requires_json: - score += 3.0 - if model.supports_tools and request.requires_tools: - score += 3.0 - score += max(0, 10 - model.quality_rank) * 1.7 - score -= model.cost_rank * 0.25 - if model.active_params_b is not None and model.active_params_b <= 8: - score += 0.5 - return round(score, 4) - - def _reasons(self, selected: ModelEndpoint | None, request: RoutingRequest) -> tuple[str, ...]: - if selected is None: - return ("no model satisfied sovereignty and capability constraints",) - reasons = [ - f"selected `{selected.model_id}` for task_type `{request.task_type}`", - f"residency `{request.jurisdiction}` satisfied", - f"runtime `{selected.runtime}`", - ] - if request.data_sensitivity in HIGH_SENSITIVITY: - reasons.append("high-sensitivity routing kept inside sovereign tier constraints") - return tuple(reasons) +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from .registry import ModelEndpoint, RoutingRequest + +HIGH_SENSITIVITY = frozenset({"personal", "restricted", "health", "iwi", "taonga"}) + + +@dataclass(frozen=True, slots=True) +class RouteDecision: + selected_model: ModelEndpoint | None + fallback_models: tuple[ModelEndpoint, ...] + rejected: tuple[dict[str, str], ...] + reasons: tuple[str, ...] + scores: dict[str, float] + + def to_dict(self) -> dict[str, Any]: + return { + "selected_model": self.selected_model.to_dict() if self.selected_model else None, + "fallback_models": [model.to_dict() for model in self.fallback_models], + "rejected": list(self.rejected), + "reasons": list(self.reasons), + "scores": dict(self.scores), + } + + +class SovereignModelRouter: + def __init__(self, models: tuple[ModelEndpoint, ...]) -> None: + self.models = models + + def route(self, request: RoutingRequest) -> RouteDecision: + accepted: list[ModelEndpoint] = [] + rejected: list[dict[str, str]] = [] + requested_modalities = set(request.modalities) + license_allowlist = set(request.license_allowlist) + + for model in self.models: + model_modalities = set(item.lower() for item in model.modalities) + if not requested_modalities.issubset(model_modalities): + rejected.append({"model_id": model.model_id, "reason": "modality_mismatch"}) + continue + if model.context_tokens < request.min_context_tokens: + rejected.append({"model_id": model.model_id, "reason": "context_too_small"}) + continue + if request.requires_tools and not model.supports_tools: + rejected.append({"model_id": model.model_id, "reason": "tools_required"}) + continue + if request.requires_json and not model.supports_json: + rejected.append({"model_id": model.model_id, "reason": "json_required"}) + continue + if license_allowlist and model.license_id.lower() not in license_allowlist: + rejected.append({"model_id": model.model_id, "reason": "license_not_allowed"}) + continue + if request.requires_local and request.jurisdiction not in model.residency: + rejected.append({"model_id": model.model_id, "reason": "residency_mismatch"}) + continue + if request.data_sensitivity in HIGH_SENSITIVITY and model.sovereignty_tier < 2: + rejected.append({"model_id": model.model_id, "reason": "sovereignty_tier_too_low"}) + continue + accepted.append(model) + + scores = {model.model_id: self._score(model, request) for model in accepted} + ranked = tuple(sorted(accepted, key=lambda model: (scores[model.model_id], model.model_id), reverse=True)) + selected = ranked[0] if ranked else None + fallbacks = ranked[1 : 1 + request.max_fallbacks] + reasons = self._reasons(selected, request) + return RouteDecision( + selected_model=selected, + fallback_models=fallbacks, + rejected=tuple(rejected), + reasons=reasons, + scores=scores, + ) + + def _score(self, model: ModelEndpoint, request: RoutingRequest) -> float: + score = 0.0 + tags = set(model.tags) + prompt_lower = request.prompt.lower() + if request.jurisdiction in model.residency: + score += 8.0 + if request.task_type in tags or request.task_type in model.family.lower(): + score += 7.0 + if request.task_type == "code" and ("coder" in model.model_id or "coder" in tags): + score += 10.0 + if request.task_type == "reasoning" and ("reasoning" in model.model_id or "reasoning" in tags): + score += 9.0 + if "iwi" in prompt_lower or "data sovereignty" in prompt_lower: + score += 3.0 * model.sovereignty_tier + if "gguf" in model.runtime.lower() or model.runtime == "llama_cpp": + score += 2.5 + if model.supports_json and request.requires_json: + score += 3.0 + if model.supports_tools and request.requires_tools: + score += 3.0 + score += max(0, 10 - model.quality_rank) * 1.7 + score -= model.cost_rank * 0.25 + if model.active_params_b is not None and model.active_params_b <= 8: + score += 0.5 + return round(score, 4) + + def _reasons(self, selected: ModelEndpoint | None, request: RoutingRequest) -> tuple[str, ...]: + if selected is None: + return ("no model satisfied sovereignty and capability constraints",) + reasons = [ + f"selected `{selected.model_id}` for task_type `{request.task_type}`", + f"residency `{request.jurisdiction}` satisfied", + f"runtime `{selected.runtime}`", + ] + if request.data_sensitivity in HIGH_SENSITIVITY: + reasons.append("high-sensitivity routing kept inside sovereign tier constraints") + return tuple(reasons) diff --git a/marama_route/schemas/data_capsule.schema.json b/marama_route/schemas/data_capsule.schema.json index 3d24daaa7314820433bb24b994023edc2d46ce80..382aeb8c81aa0bc19e22707a58392ca66e98a297 100644 --- a/marama_route/schemas/data_capsule.schema.json +++ b/marama_route/schemas/data_capsule.schema.json @@ -1,88 +1,88 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://abteex.com/schemas/sovereigncode/data-capsule.schema.json", - "title": "AbteeX SovereignCode Data Capsule", - "type": "object", - "required": ["capsule_id", "subject_id", "jurisdiction", "sensitivity"], - "additionalProperties": true, - "properties": { - "capsule_id": { - "type": "string", - "minLength": 1 - }, - "subject_id": { - "type": "string", - "minLength": 1 - }, - "jurisdiction": { - "type": "string", - "default": "NZ" - }, - "sensitivity": { - "type": "string", - "enum": ["public", "internal", "restricted", "personal", "health", "iwi", "taonga"] - }, - "allowed_purposes": { - "type": "array", - "items": { "type": "string" }, - "default": ["inference", "coding_assistance"] - }, - "denied_purposes": { - "type": "array", - "items": { "type": "string" }, - "default": [] - }, - "resident_regions": { - "type": "array", - "items": { "type": "string" }, - "default": ["NZ"] - }, - "data_classes": { - "type": "array", - "items": { "type": "string" }, - "default": ["source_code"] - }, - "retention_days": { - "type": "integer", - "minimum": 0, - "default": 30 - }, - "export_allowed": { - "type": "boolean", - "default": false - }, - "training_allowed": { - "type": "boolean", - "default": false - }, - "personal_detail_level": { - "type": "string", - "enum": ["none", "anonymous", "pseudonymous", "identifiable", "sensitive_identifiable"], - "default": "none" - }, - "consent_scopes": { - "type": "array", - "items": { "type": "string" }, - "default": [] - }, - "data_subject_rights": { - "type": "array", - "items": { "type": "string" }, - "default": ["access", "correction", "deletion_request", "processing_objection"] - }, - "revoked": { - "type": "boolean", - "default": false - }, - "schema_context": { - "type": "string", - "default": "https://schema.org" - }, - "consent_record": { - "type": "string" - }, - "metadata": { - "type": "object" - } - } -} +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://abteex.com/schemas/sovereigncode/data-capsule.schema.json", + "title": "AbteeX SovereignCode Data Capsule", + "type": "object", + "required": ["capsule_id", "subject_id", "jurisdiction", "sensitivity"], + "additionalProperties": true, + "properties": { + "capsule_id": { + "type": "string", + "minLength": 1 + }, + "subject_id": { + "type": "string", + "minLength": 1 + }, + "jurisdiction": { + "type": "string", + "default": "NZ" + }, + "sensitivity": { + "type": "string", + "enum": ["public", "internal", "restricted", "personal", "health", "iwi", "taonga"] + }, + "allowed_purposes": { + "type": "array", + "items": { "type": "string" }, + "default": ["inference", "coding_assistance"] + }, + "denied_purposes": { + "type": "array", + "items": { "type": "string" }, + "default": [] + }, + "resident_regions": { + "type": "array", + "items": { "type": "string" }, + "default": ["NZ"] + }, + "data_classes": { + "type": "array", + "items": { "type": "string" }, + "default": ["source_code"] + }, + "retention_days": { + "type": "integer", + "minimum": 0, + "default": 30 + }, + "export_allowed": { + "type": "boolean", + "default": false + }, + "training_allowed": { + "type": "boolean", + "default": false + }, + "personal_detail_level": { + "type": "string", + "enum": ["none", "anonymous", "pseudonymous", "identifiable", "sensitive_identifiable"], + "default": "none" + }, + "consent_scopes": { + "type": "array", + "items": { "type": "string" }, + "default": [] + }, + "data_subject_rights": { + "type": "array", + "items": { "type": "string" }, + "default": ["access", "correction", "deletion_request", "processing_objection"] + }, + "revoked": { + "type": "boolean", + "default": false + }, + "schema_context": { + "type": "string", + "default": "https://schema.org" + }, + "consent_record": { + "type": "string" + }, + "metadata": { + "type": "object" + } + } +} diff --git a/marama_route/schemas/openai_chat_route_request.schema.json b/marama_route/schemas/openai_chat_route_request.schema.json index e0f4e342a4e6be01a036e187046655e2583ad4fa..5c1de107347e6bca858c96c795243bbde2a933fe 100644 --- a/marama_route/schemas/openai_chat_route_request.schema.json +++ b/marama_route/schemas/openai_chat_route_request.schema.json @@ -1,95 +1,95 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://abteex.com/schemas/marama-route/openai-chat-route-request.schema.json", - "title": "MaramaRoute OpenAI-Compatible Chat Route Request", - "type": "object", - "required": ["messages"], - "additionalProperties": true, - "properties": { - "model": { - "type": "string", - "default": "lumynax/auto" - }, - "messages": { - "type": "array", - "items": { - "type": "object", - "required": ["role", "content"], - "properties": { - "role": { - "type": "string" - }, - "content": {} - }, - "additionalProperties": true - } - }, - "tools": { - "type": "array" - }, - "response_format": { - "type": "object" - }, - "route": { - "$ref": "#/$defs/routeOptions" - }, - "routing": { - "$ref": "#/$defs/routeOptions" - }, - "metadata": { - "type": "object", - "properties": { - "marama_route": { - "$ref": "#/$defs/routeOptions" - } - }, - "additionalProperties": true - } - }, - "$defs": { - "routeOptions": { - "type": "object", - "additionalProperties": true, - "properties": { - "jurisdiction": { - "type": "string", - "default": "NZ" - }, - "data_sensitivity": { - "type": "string", - "default": "internal" - }, - "task_type": { - "type": "string", - "enum": ["general", "code", "reasoning", "multimodal", "embedding"] - }, - "min_context_tokens": { - "type": "integer", - "minimum": 1, - "default": 4096 - }, - "requires_local": { - "type": "boolean", - "default": true - }, - "requires_tools": { - "type": "boolean", - "default": false - }, - "requires_json": { - "type": "boolean", - "default": false - }, - "license_allowlist": { - "type": "array", - "items": { "type": "string" } - }, - "max_fallbacks": { - "type": "integer", - "minimum": 0, - "default": 3 - } - } - } - } -} +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://abteex.com/schemas/marama-route/openai-chat-route-request.schema.json", + "title": "MaramaRoute OpenAI-Compatible Chat Route Request", + "type": "object", + "required": ["messages"], + "additionalProperties": true, + "properties": { + "model": { + "type": "string", + "default": "lumynax/auto" + }, + "messages": { + "type": "array", + "items": { + "type": "object", + "required": ["role", "content"], + "properties": { + "role": { + "type": "string" + }, + "content": {} + }, + "additionalProperties": true + } + }, + "tools": { + "type": "array" + }, + "response_format": { + "type": "object" + }, + "route": { + "$ref": "#/$defs/routeOptions" + }, + "routing": { + "$ref": "#/$defs/routeOptions" + }, + "metadata": { + "type": "object", + "properties": { + "marama_route": { + "$ref": "#/$defs/routeOptions" + } + }, + "additionalProperties": true + } + }, + "$defs": { + "routeOptions": { + "type": "object", + "additionalProperties": true, + "properties": { + "jurisdiction": { + "type": "string", + "default": "NZ" + }, + "data_sensitivity": { + "type": "string", + "default": "internal" + }, + "task_type": { + "type": "string", + "enum": ["general", "code", "reasoning", "multimodal", "embedding"] + }, + "min_context_tokens": { + "type": "integer", + "minimum": 1, + "default": 4096 + }, + "requires_local": { + "type": "boolean", + "default": true + }, + "requires_tools": { + "type": "boolean", + "default": false + }, + "requires_json": { + "type": "boolean", + "default": false + }, + "license_allowlist": { + "type": "array", + "items": { "type": "string" } + }, + "max_fallbacks": { + "type": "integer", + "minimum": 0, + "default": 3 + } + } + } + } +} diff --git a/marama_route/server.py b/marama_route/server.py new file mode 100644 index 0000000000000000000000000000000000000000..7deac6072e16b821bde5fc7f8a8455e9c0d09554 --- /dev/null +++ b/marama_route/server.py @@ -0,0 +1,312 @@ +from __future__ import annotations + +import json +import os +import tempfile +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + +try: # repo package + from tinyluminax.products._ui_server import serve_dashboard +except ModuleNotFoundError: # standalone HF package + from ._ui_server import serve_dashboard + +from .gateway import route_chat_payload +from .platform import build_models_api, route_or_chat_payload, route_receipt +from .registry import load_model_registry +from .ui import ( + PRODUCT_NAME, + build_dashboard_state, + build_expanded_dashboard_html, + default_openai_chat_request_path, + default_registry_path, + handle_api_request, + load_json_mapping, +) + +PACKAGE_ROOT = Path(__file__).resolve().parent +PACKAGE_PARENT = PACKAGE_ROOT.parent + +DEFAULT_GATEWAY_CONFIG: dict[str, Any] = { + "mode": "route_only", + "prompt_retention": "not_stored_by_default", + "default_timeout_seconds": 120, + "backends": {}, +} + + +def default_gateway_config_path() -> Path: + candidates = [ + Path.cwd() / "products" / "lumynax-marama-route" / "configs" / "gateway.local.json", + Path.cwd() / "configs" / "gateway.local.json", + PACKAGE_ROOT / "configs" / "gateway.local.json", + PACKAGE_PARENT / "configs" / "gateway.local.json", + ] + for path in candidates: + if path.exists(): + return path + return candidates[0] + + +def default_route_request_path() -> Path: + candidates = [ + Path.cwd() / "products" / "lumynax-marama-route" / "examples" / "request.code-restricted.json", + Path.cwd() / "examples" / "request.code-restricted.json", + PACKAGE_ROOT / "examples" / "request.code-restricted.json", + PACKAGE_PARENT / "examples" / "request.code-restricted.json", + ] + for path in candidates: + if path.exists(): + return path + return candidates[0] + + +def load_gateway_config(path: Path | None = None) -> dict[str, Any]: + config = dict(DEFAULT_GATEWAY_CONFIG) + config["backends"] = dict(DEFAULT_GATEWAY_CONFIG["backends"]) + resolved = path or default_gateway_config_path() + if resolved.exists(): + payload = json.loads(resolved.read_text(encoding="utf-8-sig")) + if not isinstance(payload, dict): + raise ValueError(f"Expected gateway config object in {resolved}") + config.update(payload) + config["backends"] = dict(payload.get("backends") or {}) + config["config_path"] = str(resolved) + return config + + +def handle_gateway_request( + method: str, + path: str, + payload: dict[str, Any] | None, + registry_path: Path, + config_path: Path | None = None, +) -> tuple[int, dict[str, Any]]: + models = load_model_registry(registry_path) + config = load_gateway_config(config_path) + + if path.startswith("/api/"): + return handle_api_request(method, path, payload, registry_path) + if method == "GET" and path in {"/health", "/v1/health"}: + return 200, { + "ok": True, + "product": PRODUCT_NAME, + "mode": config["mode"], + "model_count": len(models), + "configured_backends": len(config.get("backends") or {}), + "prompt_retention": config.get("prompt_retention", "not_stored_by_default"), + } + if method == "GET" and path == "/v1/models": + return 200, build_models_api(models) + if method == "POST" and path == "/v1/route" and payload is not None: + result = route_or_chat_payload(payload, models) + return (200 if result["ok"] else 422), result + if method == "POST" and path == "/v1/chat/completions" and payload is not None: + return chat_completion_gateway(payload, models, config) + return 404, {"ok": False, "error": "not_found"} + + +def chat_completion_gateway( + payload: dict[str, Any], + models: tuple[Any, ...], + config: dict[str, Any], +) -> tuple[int, dict[str, Any]]: + route_result = route_chat_payload(payload, models) + decision = route_result["route_decision"] + selected = decision.get("selected_model") + if not isinstance(selected, dict): + return 422, {"ok": False, "error": "no_eligible_model", **route_result} + + receipt = route_receipt(payload, route_result) + dry_run = bool( + payload.get("dry_run") + or payload.get("marama_route_dry_run") + or config.get("mode", "route_only") == "route_only" + ) + if dry_run: + response = dict(route_result["chat_completion_response"]) + response["marama_route"] = dict(response["marama_route"]) + response["marama_route"].update( + { + "backend_mode": "route_only", + "receipt": receipt, + "prompt_retention": config.get("prompt_retention", "not_stored_by_default"), + }, + ) + return 200, response + + backend = _backend_for_model(selected["model_id"], config) + if backend is None: + return 424, { + "ok": False, + "error": "backend_not_configured", + "message": "Routing succeeded, but no live backend is configured for the selected model.", + "selected_model": selected["model_id"], + "required_config": { + "mode": "live", + "backends": { + selected["model_id"]: { + "type": "openai_compatible", + "base_url": "http://127.0.0.1:8000/v1", + "api_key_env": "OPTIONAL_ENV_NAME", + }, + }, + }, + "receipt": receipt, + **route_result, + } + return _proxy_openai_chat_completion(payload, selected, backend, config, route_result, receipt) + + +def smoke_gateway( + *, + registry_path: Path | None = None, + config_path: Path | None = None, +) -> dict[str, Any]: + resolved_registry = registry_path or default_registry_path() + resolved_config = config_path or _temporary_route_only_config() + route_payload = load_json_mapping(default_route_request_path()) + chat_payload = load_json_mapping(default_openai_chat_request_path()) + chat_payload["dry_run"] = True + + health_status, health = handle_gateway_request("GET", "/health", None, resolved_registry, resolved_config) + models_status, models = handle_gateway_request("GET", "/v1/models", None, resolved_registry, resolved_config) + route_status, route = handle_gateway_request("POST", "/v1/route", route_payload, resolved_registry, resolved_config) + chat_status, chat = handle_gateway_request( + "POST", + "/v1/chat/completions", + chat_payload, + resolved_registry, + resolved_config, + ) + + if health_status != 200 or models_status != 200 or route_status != 200 or chat_status != 200: + raise RuntimeError("MaramaRoute gateway smoke failed") + if chat.get("object") != "chat.completion" or chat["marama_route"]["selected_model"] is None: + raise RuntimeError("MaramaRoute gateway did not return a routed chat response") + return { + "ok": True, + "product": PRODUCT_NAME, + "mode": health["mode"], + "model_count": health["model_count"], + "route_selected_model": route["route_decision"]["selected_model"]["model_id"], + "chat_selected_model": chat["marama_route"]["selected_model"]["model_id"], + "configured_backends": health["configured_backends"], + } + + +def serve_gateway( + *, + registry_path: Path | None = None, + config_path: Path | None = None, + host: str = "127.0.0.1", + port: int = 8787, + open_browser: bool = False, + smoke: bool = False, +) -> int: + resolved_registry = registry_path or default_registry_path() + if smoke: + print(json.dumps(smoke_gateway(registry_path=resolved_registry, config_path=config_path), indent=2, sort_keys=True)) + return 0 + + html = build_expanded_dashboard_html(build_dashboard_state(resolved_registry)) + return serve_dashboard( + product_name=f"{PRODUCT_NAME} Gateway", + html=html, + api_handler=lambda method, path, request_payload: handle_gateway_request( + method, + path, + request_payload, + resolved_registry, + config_path, + ), + host=host, + port=port, + open_browser=open_browser, + api_path_prefixes=("/api/", "/v1/"), + api_exact_paths=("/health",), + ) + + +def _backend_for_model(model_id: str, config: dict[str, Any]) -> dict[str, Any] | None: + backends = config.get("backends") + if not isinstance(backends, dict): + return None + backend = backends.get(model_id) or backends.get("*") + return dict(backend) if isinstance(backend, dict) else None + + +def _proxy_openai_chat_completion( + payload: dict[str, Any], + selected: dict[str, Any], + backend: dict[str, Any], + config: dict[str, Any], + route_result: dict[str, Any], + receipt: dict[str, Any], +) -> tuple[int, dict[str, Any]]: + if str(backend.get("type") or "openai_compatible") != "openai_compatible": + return 424, {"ok": False, "error": "unsupported_backend_type", "backend": backend} + + base_url = str(backend.get("base_url") or "").rstrip("/") + if not base_url: + return 424, {"ok": False, "error": "backend_base_url_missing", "selected_model": selected["model_id"]} + endpoint = f"{base_url}/chat/completions" + upstream_payload = dict(payload) + upstream_payload["model"] = str(backend.get("model") or selected["model_id"]) + for key in ("route", "routing", "dry_run", "marama_route_dry_run"): + upstream_payload.pop(key, None) + + headers = {"Content-Type": "application/json"} + api_key_env = str(backend.get("api_key_env") or "") + if api_key_env and os.getenv(api_key_env): + headers["Authorization"] = f"Bearer {os.environ[api_key_env]}" + headers.update({str(key): str(value) for key, value in dict(backend.get("headers") or {}).items()}) + + timeout = float(backend.get("timeout_seconds") or config.get("default_timeout_seconds") or 120) + request = urllib.request.Request( + endpoint, + data=json.dumps(upstream_payload).encode("utf-8"), + headers=headers, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 - operator-configured local/remote backend + body = response.read().decode("utf-8") + payload_out = json.loads(body) + if not isinstance(payload_out, dict): + raise ValueError("upstream response was not a JSON object") + payload_out["marama_route"] = { + "dry_run": False, + "selected_model": selected, + "fallback_models": route_result["route_decision"]["fallback_models"], + "rejected_count": len(route_result["route_decision"]["rejected"]), + "receipt": receipt, + "backend_base_url": base_url, + "prompt_retention": config.get("prompt_retention", "not_stored_by_default"), + } + return int(response.status), payload_out + except urllib.error.HTTPError as exc: + return exc.code, { + "ok": False, + "error": "backend_http_error", + "status": exc.code, + "body": exc.read().decode("utf-8", errors="replace"), + "receipt": receipt, + **route_result, + } + except Exception as exc: + return 502, { + "ok": False, + "error": "backend_unavailable", + "message": str(exc), + "receipt": receipt, + **route_result, + } + + +def _temporary_route_only_config() -> Path: + path = Path(tempfile.gettempdir()) / "marama-route-smoke.gateway.json" + path.write_text(json.dumps(DEFAULT_GATEWAY_CONFIG, indent=2, sort_keys=True), encoding="utf-8") + return path diff --git a/policy-packs/nz-personal-sovereignty.yaml b/policy-packs/nz-personal-sovereignty.yaml index 5da0f89ff04edf244088b0d18d33c0286f819264..96ec2bf8a8cbd94653d8a19b72b6886f20398a54 100644 --- a/policy-packs/nz-personal-sovereignty.yaml +++ b/policy-packs/nz-personal-sovereignty.yaml @@ -1,59 +1,59 @@ -policy_id: abx-sovereigncode-nz-personal-sovereignty-v0 -jurisdiction: NZ -purpose: governed personal, workspace, and community-context coding assistance -default_residency: - - NZ -allowed_personal_detail_levels: - - none - - anonymous - - pseudonymous - - identifiable - - sensitive_identifiable -default_personal_detail_level: pseudonymous -consent_scopes: - coding_assistance: - allowed_actions: - - read_context - - propose_patch - - generate_tests - blocked_actions_without_approval: - - write_file - - execute_shell - - commit - - publish - - network_export - personal_memory: - allowed_actions: - - read_preference - - summarise_profile - blocked_actions_without_approval: - - export_profile - - train_adapter - - share_with_third_party -retention_defaults: - restricted_code_days: 14 - personal_trace_days: 7 - audit_record_days: 365 -data_subject_rights: - - access - - correction - - deletion_request - - processing_objection -obligations: - - write_immutable_audit_record - - minimise_personal_detail_in_prompt - - keep_personal_trace_inside_capsule_retention - - show_diff_before_write_or_commit - - route_only_to_resident_runtime - - require_human_review_for_external_effects -model_rules: - high_impact_requires_lumynax_or_local: true - restricted_requires_nz_residency: true - public_may_route_to_approved_global: true -export_rules: - default_export_allowed: false - require_export_manifest: true - require_named_recipient: true -training_rules: - default_training_allowed: false - require_explicit_capsule_training_allowed: true +policy_id: abx-sovereigncode-nz-personal-sovereignty-v0 +jurisdiction: NZ +purpose: governed personal, workspace, and community-context coding assistance +default_residency: + - NZ +allowed_personal_detail_levels: + - none + - anonymous + - pseudonymous + - identifiable + - sensitive_identifiable +default_personal_detail_level: pseudonymous +consent_scopes: + coding_assistance: + allowed_actions: + - read_context + - propose_patch + - generate_tests + blocked_actions_without_approval: + - write_file + - execute_shell + - commit + - publish + - network_export + personal_memory: + allowed_actions: + - read_preference + - summarise_profile + blocked_actions_without_approval: + - export_profile + - train_adapter + - share_with_third_party +retention_defaults: + restricted_code_days: 14 + personal_trace_days: 7 + audit_record_days: 365 +data_subject_rights: + - access + - correction + - deletion_request + - processing_objection +obligations: + - write_immutable_audit_record + - minimise_personal_detail_in_prompt + - keep_personal_trace_inside_capsule_retention + - show_diff_before_write_or_commit + - route_only_to_resident_runtime + - require_human_review_for_external_effects +model_rules: + high_impact_requires_lumynax_or_local: true + restricted_requires_nz_residency: true + public_may_route_to_approved_global: true +export_rules: + default_export_allowed: false + require_export_manifest: true + require_named_recipient: true +training_rules: + default_training_allowed: false + require_explicit_capsule_training_allowed: true diff --git a/product_blueprint.md b/product_blueprint.md index 96bd411b12eba27930f841c3dd9335412c8e416d..7409eca1e3f2741431e8428c8dea7574f0214b80 100644 --- a/product_blueprint.md +++ b/product_blueprint.md @@ -1,73 +1,73 @@ -# AbteeX SovereignCode Product Blueprint - -## One-Sentence Product - -SovereignCode is a local-first coding agent for New Zealand teams that need code -assistance, model routing, personal-data controls, and audit-ready tool use in -one governed workflow. - -## Core User Jobs - -| User | Job | SovereignCode Response | -| --- | --- | --- | -| Individual developer | Use an AI coding assistant without exposing private files or personal preferences. | Local capsule, pseudonymous personal profile, resident model route, no training by default. | -| Startup or SME | Refactor and test private code while keeping customer data out of generic SaaS logs. | Workspace capsule, local route, diff review, audit hash. | -| Council or public-sector team | Use AI on operational code and documents with retention and residency controls. | Tenant policy pack, NZ residency, approval gates, signed audit export. | -| Iwi or community data steward | Keep community-held context under explicit purpose and consent boundaries. | High-impact sensitivity, local/LumynaX-only model rule, export denial by default. | -| Internal platform owner | Give developers one coding assistant with central policy. | OpenAI-compatible provider, CLI planner, future SSO and policy server. | - -## Product Pillars - -1. Capsule-first context: every workspace, profile, dataset, and prompt context - resolves to a Data Capsule before agent work starts. -2. Personal sovereignty: personal detail is classified before prompt assembly, - and consent scopes gate how profile context can be used. -3. Governed autonomy: read, plan, patch, test, shell, network, commit, and - publish actions are separate tool grants. -4. Open integration: OpenCode and similar clients connect through MaramaRoute's - OpenAI-compatible gateway. -5. Audit without hoarding: records retain decision hashes, obligations, model - identity, and reasons while prompt retention stays constrained. - -## Minimum Product Loop - -```text -developer asks for a coding task - -> resolve `.sovereigncode/capsule.json` - -> evaluate SovereignRequest - -> build MaramaRoute request - -> select resident LumynaX model - -> produce plan - -> request approval for writes or shell - -> apply patch - -> run tests - -> store audit record -``` - -## Product Modules To Build Next - -| Module | MVP Definition | Implementation Notes | -| --- | --- | --- | -| Workspace indexer | Reads repo files, ignores secrets/build outputs, tags data classes. | Start with `rg --files`, `.gitignore`, and capsule include/exclude rules. | -| Tool broker | Wraps file write, shell, git, package install, HTTP, and model calls. | Reuse policy decisions and emit one audit record per effectful tool call. | -| Terminal UI | Shows plan, selected model, obligations, diff, and test output. | Keep compatible with OpenCode-style terminal use. | -| Personal profile store | Keeps user preferences and memory under a personal capsule. | Local encrypted file first, tenant vault later. | -| Audit ledger | Append-only local JSONL with hash chain. | Export signed bundles for enterprise customers. | -| Tenant policy server | Central policy packs, model allowlists, API keys, quotas. | Only needed after local MVP works. | - -## Default Plans - -| Plan | Buyer | Included | -| --- | --- | --- | -| Local Developer | individual NZ developer | local capsule, local audit, MaramaRoute provider config | -| Team Sovereign | startup or SME | shared policy pack, route registry, team audit export | -| Regulated Workspace | council, health-adjacent, community data project | stronger approval gates, retention controls, signed audit, SSO-ready policy server | - -## First Non-Negotiables - -- Never train on a capsule unless `training_allowed` is explicitly true. -- Never export restricted or personal context unless `export_allowed` is true - and the request carries human approval. -- Never route high-impact data to a non-local or non-LumynaX-governed model. -- Never apply file writes without a visible diff obligation. -- Never hide selected model identity from the audit record. +# AbteeX SovereignCode Product Blueprint + +## One-Sentence Product + +SovereignCode is a local-first coding agent for New Zealand teams that need code +assistance, model routing, personal-data controls, and audit-ready tool use in +one governed workflow. + +## Core User Jobs + +| User | Job | SovereignCode Response | +| --- | --- | --- | +| Individual developer | Use an AI coding assistant without exposing private files or personal preferences. | Local capsule, pseudonymous personal profile, resident model route, no training by default. | +| Startup or SME | Refactor and test private code while keeping customer data out of generic SaaS logs. | Workspace capsule, local route, diff review, audit hash. | +| Council or public-sector team | Use AI on operational code and documents with retention and residency controls. | Tenant policy pack, NZ residency, approval gates, signed audit export. | +| Iwi or community data steward | Keep community-held context under explicit purpose and consent boundaries. | High-impact sensitivity, local/LumynaX-only model rule, export denial by default. | +| Internal platform owner | Give developers one coding assistant with central policy. | OpenAI-compatible provider, CLI planner, future SSO and policy server. | + +## Product Pillars + +1. Capsule-first context: every workspace, profile, dataset, and prompt context + resolves to a Data Capsule before agent work starts. +2. Personal sovereignty: personal detail is classified before prompt assembly, + and consent scopes gate how profile context can be used. +3. Governed autonomy: read, plan, patch, test, shell, network, commit, and + publish actions are separate tool grants. +4. Open integration: OpenCode and similar clients connect through MaramaRoute's + OpenAI-compatible gateway. +5. Audit without hoarding: records retain decision hashes, obligations, model + identity, and reasons while prompt retention stays constrained. + +## Minimum Product Loop + +```text +developer asks for a coding task + -> resolve `.sovereigncode/capsule.json` + -> evaluate SovereignRequest + -> build MaramaRoute request + -> select resident LumynaX model + -> produce plan + -> request approval for writes or shell + -> apply patch + -> run tests + -> store audit record +``` + +## Product Modules To Build Next + +| Module | MVP Definition | Implementation Notes | +| --- | --- | --- | +| Workspace indexer | Reads repo files, ignores secrets/build outputs, tags data classes. | Start with `rg --files`, `.gitignore`, and capsule include/exclude rules. | +| Tool broker | Wraps file write, shell, git, package install, HTTP, and model calls. | Reuse policy decisions and emit one audit record per effectful tool call. | +| Terminal UI | Shows plan, selected model, obligations, diff, and test output. | Keep compatible with OpenCode-style terminal use. | +| Personal profile store | Keeps user preferences and memory under a personal capsule. | Local encrypted file first, tenant vault later. | +| Audit ledger | Append-only local JSONL with hash chain. | Export signed bundles for enterprise customers. | +| Tenant policy server | Central policy packs, model allowlists, API keys, quotas. | Only needed after local MVP works. | + +## Default Plans + +| Plan | Buyer | Included | +| --- | --- | --- | +| Local Developer | individual NZ developer | local capsule, local audit, MaramaRoute provider config | +| Team Sovereign | startup or SME | shared policy pack, route registry, team audit export | +| Regulated Workspace | council, health-adjacent, community data project | stronger approval gates, retention controls, signed audit, SSO-ready policy server | + +## First Non-Negotiables + +- Never train on a capsule unless `training_allowed` is explicitly true. +- Never export restricted or personal context unless `export_allowed` is true + and the request carries human approval. +- Never route high-impact data to a non-local or non-LumynaX-governed model. +- Never apply file writes without a visible diff obligation. +- Never hide selected model identity from the audit record. diff --git a/product_manifest.json b/product_manifest.json index 6642b2f67299da4f3eb8e4886c8d017392684b40..1901cce4208a93b373d1431a92a133be72b67170 100644 --- a/product_manifest.json +++ b/product_manifest.json @@ -3,7 +3,7 @@ "slug": "abx-sovereigncode", "publisher": "AbteeX AI Labs", "family": "LumynaX sovereign products", - "stage": "product_scaffold", + "stage": "local_runtime", "positioning": "OpenCode-style local coding agent with Data Capsule sovereignty controls", "target_region": "NZ", "target_users": [ @@ -25,7 +25,9 @@ "tool_gate_check", "capsule_summary", "opencode_workspace_export", - "opencode_compatible_provider" + "opencode_compatible_provider", + "policy_api_service", + "persistent_audit_ledger" ], "runtime_entrypoints": [ "python -m tinyluminax.products.sovereigncode.cli evaluate", @@ -33,11 +35,17 @@ "python -m tinyluminax.products.sovereigncode.cli policy-matrix", "python -m tinyluminax.products.sovereigncode.cli tool-check", "python -m tinyluminax.products.sovereigncode.cli opencode-config", - "python -m tinyluminax.products.sovereigncode.cli ui" + "python -m tinyluminax.products.sovereigncode.cli ui", + "python -m tinyluminax.products.sovereigncode.cli serve", + "python -m tinyluminax.products.sovereigncode.cli audit" ], "integration_surfaces": [ "OpenAI-compatible /v1/chat/completions via MaramaRoute", "OpenAI-compatible /v1/models via MaramaRoute", + "SovereignCode GET /health and GET /v1/audit", + "SovereignCode POST /v1/evaluate", + "SovereignCode POST /v1/plan-turn", + "SovereignCode POST /v1/tool-check", "OpenCode custom provider config", "local CLI policy evaluator", "governed coding-turn planner", diff --git a/pyproject.toml b/pyproject.toml index 5091933f5c296d874fc9944b8189f982a17ce501..ea38275ca66961ae5ab7441a374fd19128630489 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "abteex-sovereigncode" -version = "0.2.0" +version = "0.3.0" description = "AbteeX SovereignCode: local-first coding agent with Data Capsule sovereignty controls." readme = "README.md" requires-python = ">=3.11" diff --git a/quickstart.py b/quickstart.py index f8c8349110a19b588ba5eb82d176e7b38d703bb5..d19bb24c552ffbff9748995e61359b7b58fdee40 100644 --- a/quickstart.py +++ b/quickstart.py @@ -28,6 +28,10 @@ if __name__ == "__main__": "ui", "--smoke", ], + [ + "serve", + "--smoke", + ], [ "policy-matrix", "--capsule", diff --git a/schemas/data_capsule.schema.json b/schemas/data_capsule.schema.json index 3d24daaa7314820433bb24b994023edc2d46ce80..382aeb8c81aa0bc19e22707a58392ca66e98a297 100644 --- a/schemas/data_capsule.schema.json +++ b/schemas/data_capsule.schema.json @@ -1,88 +1,88 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://abteex.com/schemas/sovereigncode/data-capsule.schema.json", - "title": "AbteeX SovereignCode Data Capsule", - "type": "object", - "required": ["capsule_id", "subject_id", "jurisdiction", "sensitivity"], - "additionalProperties": true, - "properties": { - "capsule_id": { - "type": "string", - "minLength": 1 - }, - "subject_id": { - "type": "string", - "minLength": 1 - }, - "jurisdiction": { - "type": "string", - "default": "NZ" - }, - "sensitivity": { - "type": "string", - "enum": ["public", "internal", "restricted", "personal", "health", "iwi", "taonga"] - }, - "allowed_purposes": { - "type": "array", - "items": { "type": "string" }, - "default": ["inference", "coding_assistance"] - }, - "denied_purposes": { - "type": "array", - "items": { "type": "string" }, - "default": [] - }, - "resident_regions": { - "type": "array", - "items": { "type": "string" }, - "default": ["NZ"] - }, - "data_classes": { - "type": "array", - "items": { "type": "string" }, - "default": ["source_code"] - }, - "retention_days": { - "type": "integer", - "minimum": 0, - "default": 30 - }, - "export_allowed": { - "type": "boolean", - "default": false - }, - "training_allowed": { - "type": "boolean", - "default": false - }, - "personal_detail_level": { - "type": "string", - "enum": ["none", "anonymous", "pseudonymous", "identifiable", "sensitive_identifiable"], - "default": "none" - }, - "consent_scopes": { - "type": "array", - "items": { "type": "string" }, - "default": [] - }, - "data_subject_rights": { - "type": "array", - "items": { "type": "string" }, - "default": ["access", "correction", "deletion_request", "processing_objection"] - }, - "revoked": { - "type": "boolean", - "default": false - }, - "schema_context": { - "type": "string", - "default": "https://schema.org" - }, - "consent_record": { - "type": "string" - }, - "metadata": { - "type": "object" - } - } -} +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://abteex.com/schemas/sovereigncode/data-capsule.schema.json", + "title": "AbteeX SovereignCode Data Capsule", + "type": "object", + "required": ["capsule_id", "subject_id", "jurisdiction", "sensitivity"], + "additionalProperties": true, + "properties": { + "capsule_id": { + "type": "string", + "minLength": 1 + }, + "subject_id": { + "type": "string", + "minLength": 1 + }, + "jurisdiction": { + "type": "string", + "default": "NZ" + }, + "sensitivity": { + "type": "string", + "enum": ["public", "internal", "restricted", "personal", "health", "iwi", "taonga"] + }, + "allowed_purposes": { + "type": "array", + "items": { "type": "string" }, + "default": ["inference", "coding_assistance"] + }, + "denied_purposes": { + "type": "array", + "items": { "type": "string" }, + "default": [] + }, + "resident_regions": { + "type": "array", + "items": { "type": "string" }, + "default": ["NZ"] + }, + "data_classes": { + "type": "array", + "items": { "type": "string" }, + "default": ["source_code"] + }, + "retention_days": { + "type": "integer", + "minimum": 0, + "default": 30 + }, + "export_allowed": { + "type": "boolean", + "default": false + }, + "training_allowed": { + "type": "boolean", + "default": false + }, + "personal_detail_level": { + "type": "string", + "enum": ["none", "anonymous", "pseudonymous", "identifiable", "sensitive_identifiable"], + "default": "none" + }, + "consent_scopes": { + "type": "array", + "items": { "type": "string" }, + "default": [] + }, + "data_subject_rights": { + "type": "array", + "items": { "type": "string" }, + "default": ["access", "correction", "deletion_request", "processing_objection"] + }, + "revoked": { + "type": "boolean", + "default": false + }, + "schema_context": { + "type": "string", + "default": "https://schema.org" + }, + "consent_record": { + "type": "string" + }, + "metadata": { + "type": "object" + } + } +} diff --git a/schemas/openai_chat_route_request.schema.json b/schemas/openai_chat_route_request.schema.json index e0f4e342a4e6be01a036e187046655e2583ad4fa..5c1de107347e6bca858c96c795243bbde2a933fe 100644 --- a/schemas/openai_chat_route_request.schema.json +++ b/schemas/openai_chat_route_request.schema.json @@ -1,95 +1,95 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://abteex.com/schemas/marama-route/openai-chat-route-request.schema.json", - "title": "MaramaRoute OpenAI-Compatible Chat Route Request", - "type": "object", - "required": ["messages"], - "additionalProperties": true, - "properties": { - "model": { - "type": "string", - "default": "lumynax/auto" - }, - "messages": { - "type": "array", - "items": { - "type": "object", - "required": ["role", "content"], - "properties": { - "role": { - "type": "string" - }, - "content": {} - }, - "additionalProperties": true - } - }, - "tools": { - "type": "array" - }, - "response_format": { - "type": "object" - }, - "route": { - "$ref": "#/$defs/routeOptions" - }, - "routing": { - "$ref": "#/$defs/routeOptions" - }, - "metadata": { - "type": "object", - "properties": { - "marama_route": { - "$ref": "#/$defs/routeOptions" - } - }, - "additionalProperties": true - } - }, - "$defs": { - "routeOptions": { - "type": "object", - "additionalProperties": true, - "properties": { - "jurisdiction": { - "type": "string", - "default": "NZ" - }, - "data_sensitivity": { - "type": "string", - "default": "internal" - }, - "task_type": { - "type": "string", - "enum": ["general", "code", "reasoning", "multimodal", "embedding"] - }, - "min_context_tokens": { - "type": "integer", - "minimum": 1, - "default": 4096 - }, - "requires_local": { - "type": "boolean", - "default": true - }, - "requires_tools": { - "type": "boolean", - "default": false - }, - "requires_json": { - "type": "boolean", - "default": false - }, - "license_allowlist": { - "type": "array", - "items": { "type": "string" } - }, - "max_fallbacks": { - "type": "integer", - "minimum": 0, - "default": 3 - } - } - } - } -} +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://abteex.com/schemas/marama-route/openai-chat-route-request.schema.json", + "title": "MaramaRoute OpenAI-Compatible Chat Route Request", + "type": "object", + "required": ["messages"], + "additionalProperties": true, + "properties": { + "model": { + "type": "string", + "default": "lumynax/auto" + }, + "messages": { + "type": "array", + "items": { + "type": "object", + "required": ["role", "content"], + "properties": { + "role": { + "type": "string" + }, + "content": {} + }, + "additionalProperties": true + } + }, + "tools": { + "type": "array" + }, + "response_format": { + "type": "object" + }, + "route": { + "$ref": "#/$defs/routeOptions" + }, + "routing": { + "$ref": "#/$defs/routeOptions" + }, + "metadata": { + "type": "object", + "properties": { + "marama_route": { + "$ref": "#/$defs/routeOptions" + } + }, + "additionalProperties": true + } + }, + "$defs": { + "routeOptions": { + "type": "object", + "additionalProperties": true, + "properties": { + "jurisdiction": { + "type": "string", + "default": "NZ" + }, + "data_sensitivity": { + "type": "string", + "default": "internal" + }, + "task_type": { + "type": "string", + "enum": ["general", "code", "reasoning", "multimodal", "embedding"] + }, + "min_context_tokens": { + "type": "integer", + "minimum": 1, + "default": 4096 + }, + "requires_local": { + "type": "boolean", + "default": true + }, + "requires_tools": { + "type": "boolean", + "default": false + }, + "requires_json": { + "type": "boolean", + "default": false + }, + "license_allowlist": { + "type": "array", + "items": { "type": "string" } + }, + "max_fallbacks": { + "type": "integer", + "minimum": 0, + "default": 3 + } + } + } + } +} diff --git a/sovereigncode/__init__.py b/sovereigncode/__init__.py index 4e1693b75ae539d2b178eb759bae39517ced19e4..db7bbf4983b409c220957b6a8d578fdb66bcc517 100644 --- a/sovereigncode/__init__.py +++ b/sovereigncode/__init__.py @@ -1,6 +1,7 @@ from __future__ import annotations from .audit import AuditRecord, build_audit_record +from .ledger import AuditLedger from .planner import SovereignCodingTurnPlan, ToolGrant, plan_coding_turn from .platform import ( build_capsule_summary, @@ -15,10 +16,12 @@ from .policy import ( SovereignRequest, SovereigntyPolicyEngine, ) +from .server import handle_service_request, smoke_service from .ui import smoke_ui as smoke_ui __all__ = [ "AuditRecord", + "AuditLedger", "DataCapsule", "PolicyDecision", "SovereignRequest", @@ -31,6 +34,8 @@ __all__ = [ "build_policy_matrix", "build_turn_brief", "check_tool_request", + "handle_service_request", "plan_coding_turn", + "smoke_service", "smoke_ui", ] diff --git a/sovereigncode/_ui_server.py b/sovereigncode/_ui_server.py index 140a1e907215d114163d02e284d81737df374630..e4b6da2ca0fbac971424b86afd9df28affcd9401 100644 --- a/sovereigncode/_ui_server.py +++ b/sovereigncode/_ui_server.py @@ -37,8 +37,14 @@ def serve_dashboard( host: str, port: int, open_browser: bool = False, + api_path_prefixes: tuple[str, ...] = ("/api/",), + api_exact_paths: tuple[str, ...] = (), ) -> int: actual_port = find_available_port(host, port) + exact_paths = set(api_exact_paths) + + def is_api_path(path: str) -> bool: + return path in exact_paths or any(path.startswith(prefix) for prefix in api_path_prefixes) class Handler(BaseHTTPRequestHandler): server_version = "AbteeXProductUI/0.1" @@ -48,14 +54,14 @@ def serve_dashboard( if path == "/": self._send_text(200, html, "text/html; charset=utf-8") return - if path.startswith("/api/"): + if is_api_path(path): self._send_api("GET", path, None) return self._send_json(404, {"ok": False, "error": "not_found"}) def do_POST(self) -> None: # noqa: N802 - stdlib handler method name path = urlparse(self.path).path - if not path.startswith("/api/"): + if not is_api_path(path): self._send_json(404, {"ok": False, "error": "not_found"}) return try: diff --git a/sovereigncode/audit.py b/sovereigncode/audit.py index 0ccc96d51c881f0c72f5ab1bb6573ca3740becc4..d252554394045aa40bdf8ee3913223b69253d40c 100644 --- a/sovereigncode/audit.py +++ b/sovereigncode/audit.py @@ -1,61 +1,61 @@ -from __future__ import annotations - -import hashlib -from dataclasses import dataclass -from datetime import UTC, datetime -from typing import Any - -from .policy import DataCapsule, PolicyDecision, SovereignRequest - - -@dataclass(frozen=True, slots=True) -class AuditRecord: - timestamp: str - capsule_id: str - actor: str - purpose: str - action: str - model_id: str - allowed: bool - reasons: tuple[str, ...] - obligations: tuple[str, ...] - audit_tags: tuple[str, ...] - request_hash: str - - def to_dict(self) -> dict[str, Any]: - return { - "timestamp": self.timestamp, - "capsule_id": self.capsule_id, - "actor": self.actor, - "purpose": self.purpose, - "action": self.action, - "model_id": self.model_id, - "allowed": self.allowed, - "reasons": list(self.reasons), - "obligations": list(self.obligations), - "audit_tags": list(self.audit_tags), - "request_hash": self.request_hash, - } - - -def build_audit_record( - capsule: DataCapsule, - request: SovereignRequest, - decision: PolicyDecision, -) -> AuditRecord: - digest = hashlib.sha256( - repr((capsule.to_dict(), request.to_dict(), decision.to_dict())).encode("utf-8"), - ).hexdigest() - return AuditRecord( - timestamp=datetime.now(UTC).isoformat(), - capsule_id=capsule.capsule_id, - actor=request.actor, - purpose=request.purpose, - action=request.action, - model_id=request.model_id, - allowed=decision.allowed, - reasons=decision.reasons, - obligations=decision.obligations, - audit_tags=decision.audit_tags, - request_hash=digest, - ) +from __future__ import annotations + +import hashlib +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Any + +from .policy import DataCapsule, PolicyDecision, SovereignRequest + + +@dataclass(frozen=True, slots=True) +class AuditRecord: + timestamp: str + capsule_id: str + actor: str + purpose: str + action: str + model_id: str + allowed: bool + reasons: tuple[str, ...] + obligations: tuple[str, ...] + audit_tags: tuple[str, ...] + request_hash: str + + def to_dict(self) -> dict[str, Any]: + return { + "timestamp": self.timestamp, + "capsule_id": self.capsule_id, + "actor": self.actor, + "purpose": self.purpose, + "action": self.action, + "model_id": self.model_id, + "allowed": self.allowed, + "reasons": list(self.reasons), + "obligations": list(self.obligations), + "audit_tags": list(self.audit_tags), + "request_hash": self.request_hash, + } + + +def build_audit_record( + capsule: DataCapsule, + request: SovereignRequest, + decision: PolicyDecision, +) -> AuditRecord: + digest = hashlib.sha256( + repr((capsule.to_dict(), request.to_dict(), decision.to_dict())).encode("utf-8"), + ).hexdigest() + return AuditRecord( + timestamp=datetime.now(UTC).isoformat(), + capsule_id=capsule.capsule_id, + actor=request.actor, + purpose=request.purpose, + action=request.action, + model_id=request.model_id, + allowed=decision.allowed, + reasons=decision.reasons, + obligations=decision.obligations, + audit_tags=decision.audit_tags, + request_hash=digest, + ) diff --git a/sovereigncode/cli.py b/sovereigncode/cli.py index 200ba9797a8e24a778db0aaa99aed3e29cda0eee..cc2d186dbd3e8a75c3f491a87ca6d6aac2748cf4 100644 --- a/sovereigncode/cli.py +++ b/sovereigncode/cli.py @@ -111,6 +111,30 @@ def _ui(args: argparse.Namespace) -> int: ) +def _serve(args: argparse.Namespace) -> int: + from .server import serve_service + + return serve_service( + capsule_path=args.capsule, + request_path=args.request, + route_request_path=args.route_request, + registry_path=args.registry, + ledger_path=args.ledger, + host=args.host, + port=args.port, + open_browser=args.open, + smoke=args.smoke, + ) + + +def _audit(args: argparse.Namespace) -> int: + from .ledger import AuditLedger, default_ledger_path + + ledger = AuditLedger(args.ledger or default_ledger_path()) + print(json.dumps({"ok": True, "ledger_path": str(ledger.path), "records": ledger.tail(args.limit)}, indent=2, sort_keys=True)) + return 0 + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="abteex-sovereigncode", @@ -206,6 +230,26 @@ def build_parser() -> argparse.ArgumentParser: ui.add_argument("--open", action=argparse.BooleanOptionalAction, default=False) ui.add_argument("--smoke", action=argparse.BooleanOptionalAction, default=False) ui.set_defaults(handler=_ui) + + serve = subparsers.add_parser( + "serve", + help="Run the local SovereignCode policy API, audit ledger, and browser console.", + ) + serve.add_argument("--capsule", type=Path, default=None, help="Data Capsule JSON/YAML file.") + serve.add_argument("--request", type=Path, default=None, help="Sovereign request JSON/YAML file.") + serve.add_argument("--route-request", type=Path, default=None, help="MaramaRoute routing request JSON.") + serve.add_argument("--registry", type=Path, default=None, help="MaramaRoute model registry JSON.") + serve.add_argument("--ledger", type=Path, default=None, help="Audit ledger JSONL path.") + serve.add_argument("--host", type=str, default="127.0.0.1") + serve.add_argument("--port", type=int, default=8788) + serve.add_argument("--open", action=argparse.BooleanOptionalAction, default=False) + serve.add_argument("--smoke", action=argparse.BooleanOptionalAction, default=False) + serve.set_defaults(handler=_serve) + + audit = subparsers.add_parser("audit", help="Read the local SovereignCode audit ledger.") + audit.add_argument("--ledger", type=Path, default=None, help="Audit ledger JSONL path.") + audit.add_argument("--limit", type=int, default=25) + audit.set_defaults(handler=_audit) return parser diff --git a/sovereigncode/configs/default_policy.yaml b/sovereigncode/configs/default_policy.yaml index 65d01fb77ebfaec1d859c96df8e6e8d455526c6b..fc5af8fd8a5cb31e710a633835b0fce7184276e3 100644 --- a/sovereigncode/configs/default_policy.yaml +++ b/sovereigncode/configs/default_policy.yaml @@ -1,26 +1,26 @@ -policy_id: abx-sovereigncode-default-v0 -default_jurisdiction: NZ -default_resident_regions: - - NZ -high_impact_sensitivity: - - personal - - restricted - - health - - iwi - - taonga -default_obligations: - - write_immutable_audit_record - - preserve_capsule_id_in_agent_trace - - show_diff_before_write_or_commit -denied_without_human_approval: - - delete_file - - execute_shell - - network_export - - publish - - commit -remote_model_rule: - restricted_data_requires_local_or_lumynax: true -training_rule: - requires_capsule_training_allowed: true -export_rule: - requires_capsule_export_allowed: true +policy_id: abx-sovereigncode-default-v0 +default_jurisdiction: NZ +default_resident_regions: + - NZ +high_impact_sensitivity: + - personal + - restricted + - health + - iwi + - taonga +default_obligations: + - write_immutable_audit_record + - preserve_capsule_id_in_agent_trace + - show_diff_before_write_or_commit +denied_without_human_approval: + - delete_file + - execute_shell + - network_export + - publish + - commit +remote_model_rule: + restricted_data_requires_local_or_lumynax: true +training_rule: + requires_capsule_training_allowed: true +export_rule: + requires_capsule_export_allowed: true diff --git a/sovereigncode/configs/gateway.local.json b/sovereigncode/configs/gateway.local.json new file mode 100644 index 0000000000000000000000000000000000000000..bf157d04a883814a48a79c6376f9cfa7e8be83b9 --- /dev/null +++ b/sovereigncode/configs/gateway.local.json @@ -0,0 +1,13 @@ +{ + "mode": "route_only", + "prompt_retention": "not_stored_by_default", + "default_timeout_seconds": 120, + "backends": { + "example-local-openai-compatible": { + "type": "openai_compatible", + "base_url": "http://127.0.0.1:8000/v1", + "api_key_env": "", + "model": "local-model-id" + } + } +} diff --git a/sovereigncode/configs/provider_aliases.yaml b/sovereigncode/configs/provider_aliases.yaml index 6662ff15c2e5336e94582f461956e92f0a5ab36d..b0c9b863187b55008de960750bcabeb4d16f11a7 100644 --- a/sovereigncode/configs/provider_aliases.yaml +++ b/sovereigncode/configs/provider_aliases.yaml @@ -1,30 +1,30 @@ -provider_id: abteex-marama -base_path: /v1 -default_model_alias: lumynax/auto -aliases: - lumynax/auto: - task_type: general - requires_local: true - description: Select the best resident LumynaX model for the request. - lumynax/code: - task_type: code - requires_local: true - requires_json: true - description: Prefer coder-tagged LumynaX models with tool and JSON support. - lumynax/reasoning: - task_type: reasoning - requires_local: true - description: Prefer reasoning-tagged models inside residency constraints. - lumynax/multimodal: - task_type: multimodal - requires_local: false - description: Prefer text-plus-image LumynaX models when policy allows. -default_route: - jurisdiction: NZ - data_sensitivity: internal - min_context_tokens: 4096 - max_fallbacks: 3 -telemetry: - retain_prompt_by_default: false - retain_route_decision_days: 365 - hash_request_payload: true +provider_id: abteex-marama +base_path: /v1 +default_model_alias: lumynax/auto +aliases: + lumynax/auto: + task_type: general + requires_local: true + description: Select the best resident LumynaX model for the request. + lumynax/code: + task_type: code + requires_local: true + requires_json: true + description: Prefer coder-tagged LumynaX models with tool and JSON support. + lumynax/reasoning: + task_type: reasoning + requires_local: true + description: Prefer reasoning-tagged models inside residency constraints. + lumynax/multimodal: + task_type: multimodal + requires_local: false + description: Prefer text-plus-image LumynaX models when policy allows. +default_route: + jurisdiction: NZ + data_sensitivity: internal + min_context_tokens: 4096 + max_fallbacks: 3 +telemetry: + retain_prompt_by_default: false + retain_route_decision_days: 365 + hash_request_payload: true diff --git a/sovereigncode/configs/routing_policy.yaml b/sovereigncode/configs/routing_policy.yaml index 947eabe01b553dd2c38720e219c2d0acac84775c..8c44a75f18ae0368e32236e6c620aa6a4d5ceb14 100644 --- a/sovereigncode/configs/routing_policy.yaml +++ b/sovereigncode/configs/routing_policy.yaml @@ -1,20 +1,20 @@ -policy_id: lumynax-marama-route-default-v0 -default_jurisdiction: NZ -default_requires_local: true -high_sensitivity: - - personal - - restricted - - health - - iwi - - taonga -required_for_high_sensitivity: - min_sovereignty_tier: 2 - residency_must_match_request_jurisdiction: true - retain_prompt_by_default: false -preferred_runtimes: - - llama_cpp - - transformers_multimodal - - python_embedding -fallbacks: - max_default_fallbacks: 3 - include_rejection_reasons: true +policy_id: lumynax-marama-route-default-v0 +default_jurisdiction: NZ +default_requires_local: true +high_sensitivity: + - personal + - restricted + - health + - iwi + - taonga +required_for_high_sensitivity: + min_sovereignty_tier: 2 + residency_must_match_request_jurisdiction: true + retain_prompt_by_default: false +preferred_runtimes: + - llama_cpp + - transformers_multimodal + - python_embedding +fallbacks: + max_default_fallbacks: 3 + include_rejection_reasons: true diff --git a/sovereigncode/configs/service.local.json b/sovereigncode/configs/service.local.json new file mode 100644 index 0000000000000000000000000000000000000000..1ca64dc192a57fcb4d421a6c053aac2619781e0b --- /dev/null +++ b/sovereigncode/configs/service.local.json @@ -0,0 +1,14 @@ +{ + "ledger_path": ".sovereigncode/audit.jsonl", + "default_region": "NZ", + "fail_closed": true, + "require_human_review_for": [ + "write_files", + "execute_shell", + "network_export", + "commit", + "publish", + "train_model" + ], + "marama_route_base_url": "http://127.0.0.1:8787/v1" +} diff --git a/sovereigncode/examples/capsule.personal-sovereignty-profile.json b/sovereigncode/examples/capsule.personal-sovereignty-profile.json index 0822ee8ac5d97212c058f27b3a621093442a29fd..1d191825fd0f4d32397adb6d476401322dcc6af3 100644 --- a/sovereigncode/examples/capsule.personal-sovereignty-profile.json +++ b/sovereigncode/examples/capsule.personal-sovereignty-profile.json @@ -1,45 +1,45 @@ -{ - "capsule_id": "cap-personal-profile-001", - "subject_id": "operator-local-profile", - "jurisdiction": "NZ", - "sensitivity": "personal", - "allowed_purposes": [ - "personal_memory", - "coding_assistance", - "inference" - ], - "denied_purposes": [ - "ad_training", - "third_party_resale", - "public_leaderboard" - ], - "resident_regions": [ - "NZ" - ], - "data_classes": [ - "personal", - "preferences", - "source_code", - "runtime_logs" - ], - "retention_days": 7, - "export_allowed": false, - "training_allowed": false, - "personal_detail_level": "pseudonymous", - "consent_scopes": [ - "personal_memory", - "coding_assistance" - ], - "data_subject_rights": [ - "access", - "correction", - "deletion_request", - "processing_objection" - ], - "schema_context": "https://schema.org", - "consent_record": "local-profile-consent-v0", - "metadata": { - "storage": "local_encrypted_profile_store", - "prompt_rule": "summarise preferences without exposing raw personal notes" - } -} +{ + "capsule_id": "cap-personal-profile-001", + "subject_id": "operator-local-profile", + "jurisdiction": "NZ", + "sensitivity": "personal", + "allowed_purposes": [ + "personal_memory", + "coding_assistance", + "inference" + ], + "denied_purposes": [ + "ad_training", + "third_party_resale", + "public_leaderboard" + ], + "resident_regions": [ + "NZ" + ], + "data_classes": [ + "personal", + "preferences", + "source_code", + "runtime_logs" + ], + "retention_days": 7, + "export_allowed": false, + "training_allowed": false, + "personal_detail_level": "pseudonymous", + "consent_scopes": [ + "personal_memory", + "coding_assistance" + ], + "data_subject_rights": [ + "access", + "correction", + "deletion_request", + "processing_objection" + ], + "schema_context": "https://schema.org", + "consent_record": "local-profile-consent-v0", + "metadata": { + "storage": "local_encrypted_profile_store", + "prompt_rule": "summarise preferences without exposing raw personal notes" + } +} diff --git a/sovereigncode/examples/capsule.restricted-nz-code.json b/sovereigncode/examples/capsule.restricted-nz-code.json index 450943e104ed2dbfef4446b5373d40e0f0976f70..fac9db716019dec0decdcc18f1c38410c9090ab2 100644 --- a/sovereigncode/examples/capsule.restricted-nz-code.json +++ b/sovereigncode/examples/capsule.restricted-nz-code.json @@ -1,28 +1,28 @@ -{ - "capsule_id": "cap-nz-code-001", - "subject_id": "abx-workspace", - "jurisdiction": "NZ", - "sensitivity": "restricted", - "allowed_purposes": [ - "coding_assistance", - "inference", - "test_generation" - ], - "denied_purposes": [ - "ad_training", - "third_party_resale" - ], - "resident_regions": [ - "NZ" - ], - "data_classes": [ - "source_code", - "policy", - "runtime_logs" - ], - "retention_days": 14, - "export_allowed": false, - "training_allowed": false, - "schema_context": "https://schema.org", - "consent_record": "local-operator-policy-v0" -} +{ + "capsule_id": "cap-nz-code-001", + "subject_id": "abx-workspace", + "jurisdiction": "NZ", + "sensitivity": "restricted", + "allowed_purposes": [ + "coding_assistance", + "inference", + "test_generation" + ], + "denied_purposes": [ + "ad_training", + "third_party_resale" + ], + "resident_regions": [ + "NZ" + ], + "data_classes": [ + "source_code", + "policy", + "runtime_logs" + ], + "retention_days": 14, + "export_allowed": false, + "training_allowed": false, + "schema_context": "https://schema.org", + "consent_record": "local-operator-policy-v0" +} diff --git a/sovereigncode/examples/opencode.marama-route.json b/sovereigncode/examples/opencode.marama-route.json index 56755b01f178e8b3eaf84506355ba76a6a012636..368950327273587223a097dadf52622907f96044 100644 --- a/sovereigncode/examples/opencode.marama-route.json +++ b/sovereigncode/examples/opencode.marama-route.json @@ -1,28 +1,28 @@ -{ - "$schema": "https://opencode.ai/config.json", - "provider": { - "abteex-marama": { - "npm": "@ai-sdk/openai-compatible", - "name": "AbteeX MaramaRoute", - "options": { - "baseURL": "http://127.0.0.1:8787/v1", - "apiKey": "{env:ABTEEX_MARAMA_API_KEY}", - "headers": { - "X-AbteeX-Tenant": "{env:ABTEEX_TENANT_ID}", - "X-AbteeX-Workspace-Capsule": "{env:SOVEREIGNCODE_CAPSULE_ID}" - } - }, - "models": { - "lumynax/auto": { - "name": "LumynaX Auto Sovereign Route" - }, - "lumynax/code": { - "name": "LumynaX Code Route" - }, - "lumynax/reasoning": { - "name": "LumynaX Reasoning Route" - } - } - } - } -} +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "abteex-marama": { + "npm": "@ai-sdk/openai-compatible", + "name": "AbteeX MaramaRoute", + "options": { + "baseURL": "http://127.0.0.1:8787/v1", + "apiKey": "{env:ABTEEX_MARAMA_API_KEY}", + "headers": { + "X-AbteeX-Tenant": "{env:ABTEEX_TENANT_ID}", + "X-AbteeX-Workspace-Capsule": "{env:SOVEREIGNCODE_CAPSULE_ID}" + } + }, + "models": { + "lumynax/auto": { + "name": "LumynaX Auto Sovereign Route" + }, + "lumynax/code": { + "name": "LumynaX Code Route" + }, + "lumynax/reasoning": { + "name": "LumynaX Reasoning Route" + } + } + } + } +} diff --git a/sovereigncode/examples/request.allowed-local-edit.json b/sovereigncode/examples/request.allowed-local-edit.json index f7c6b2b49fa5b87c0a92c865ef468bef91359f84..75099a33e143f9cfab9357283a1797a65c790090 100644 --- a/sovereigncode/examples/request.allowed-local-edit.json +++ b/sovereigncode/examples/request.allowed-local-edit.json @@ -1,15 +1,15 @@ -{ - "actor": "developer", - "purpose": "coding_assistance", - "action": "read_context", - "region": "NZ", - "model_id": "AbteeXAILab/lumynax-infused-qwen3-8b-gguf", - "data_classes": [ - "source_code" - ], - "tool_name": "workspace_reader", - "writes_files": false, - "exports_data": false, - "trains_model": false, - "human_approved": false -} +{ + "actor": "developer", + "purpose": "coding_assistance", + "action": "read_context", + "region": "NZ", + "model_id": "AbteeXAILab/lumynax-infused-qwen3-8b-gguf", + "data_classes": [ + "source_code" + ], + "tool_name": "workspace_reader", + "writes_files": false, + "exports_data": false, + "trains_model": false, + "human_approved": false +} diff --git a/sovereigncode/examples/request.code-restricted.json b/sovereigncode/examples/request.code-restricted.json index a52b303e6839ac2e8837049f30f4cc0427864d37..8af0ca71616e49a78f1e5241d8763c7d0ff96d94 100644 --- a/sovereigncode/examples/request.code-restricted.json +++ b/sovereigncode/examples/request.code-restricted.json @@ -1,14 +1,14 @@ -{ - "prompt": "Refactor this private Python service and explain the diff.", - "task_type": "code", - "modalities": [ - "text" - ], - "jurisdiction": "NZ", - "data_sensitivity": "restricted", - "min_context_tokens": 4096, - "requires_local": true, - "requires_tools": false, - "requires_json": true, - "max_fallbacks": 3 -} +{ + "prompt": "Refactor this private Python service and explain the diff.", + "task_type": "code", + "modalities": [ + "text" + ], + "jurisdiction": "NZ", + "data_sensitivity": "restricted", + "min_context_tokens": 4096, + "requires_local": true, + "requires_tools": false, + "requires_json": true, + "max_fallbacks": 3 +} diff --git a/sovereigncode/examples/request.denied-training.json b/sovereigncode/examples/request.denied-training.json index 5665c131411f60fe6488833743303f5e936e8060..264055a6d05c224a90472db24b5c156773b773d1 100644 --- a/sovereigncode/examples/request.denied-training.json +++ b/sovereigncode/examples/request.denied-training.json @@ -1,15 +1,15 @@ -{ - "actor": "developer", - "purpose": "coding_assistance", - "action": "train_adapter", - "region": "NZ", - "model_id": "local/lumynax", - "data_classes": [ - "source_code" - ], - "tool_name": "trainer", - "writes_files": true, - "exports_data": false, - "trains_model": true, - "human_approved": true -} +{ + "actor": "developer", + "purpose": "coding_assistance", + "action": "train_adapter", + "region": "NZ", + "model_id": "local/lumynax", + "data_classes": [ + "source_code" + ], + "tool_name": "trainer", + "writes_files": true, + "exports_data": false, + "trains_model": true, + "human_approved": true +} diff --git a/sovereigncode/examples/request.openai-chat-code.json b/sovereigncode/examples/request.openai-chat-code.json index 252dc5d29b7e5a0559a48f8525585b6b4434a6d0..379b86aa32efff2ba361074435c335bbedeb91cf 100644 --- a/sovereigncode/examples/request.openai-chat-code.json +++ b/sovereigncode/examples/request.openai-chat-code.json @@ -1,44 +1,44 @@ -{ - "model": "lumynax/code", - "messages": [ - { - "role": "system", - "content": "You are a governed coding assistant for a New Zealand workspace." - }, - { - "role": "user", - "content": "Refactor this private Python repository function and return a JSON diff plan." - } - ], - "response_format": { - "type": "json_object" - }, - "tools": [ - { - "type": "function", - "function": { - "name": "propose_patch", - "description": "Propose a patch without writing it.", - "parameters": { - "type": "object", - "properties": { - "files": { - "type": "array", - "items": { "type": "string" } - } - } - } - } - } - ], - "route": { - "jurisdiction": "NZ", - "data_sensitivity": "restricted", - "task_type": "code", - "requires_local": true, - "requires_tools": true, - "requires_json": true, - "min_context_tokens": 4096, - "max_fallbacks": 3 - } -} +{ + "model": "lumynax/code", + "messages": [ + { + "role": "system", + "content": "You are a governed coding assistant for a New Zealand workspace." + }, + { + "role": "user", + "content": "Refactor this private Python repository function and return a JSON diff plan." + } + ], + "response_format": { + "type": "json_object" + }, + "tools": [ + { + "type": "function", + "function": { + "name": "propose_patch", + "description": "Propose a patch without writing it.", + "parameters": { + "type": "object", + "properties": { + "files": { + "type": "array", + "items": { "type": "string" } + } + } + } + } + } + ], + "route": { + "jurisdiction": "NZ", + "data_sensitivity": "restricted", + "task_type": "code", + "requires_local": true, + "requires_tools": true, + "requires_json": true, + "min_context_tokens": 4096, + "max_fallbacks": 3 + } +} diff --git a/sovereigncode/examples/request.personal-memory-read.json b/sovereigncode/examples/request.personal-memory-read.json index 61d6a53d199cefcee18c0368af5ea32a0e267ca9..7a67f6595ec9d46cea3c3d18af81e53974154d78 100644 --- a/sovereigncode/examples/request.personal-memory-read.json +++ b/sovereigncode/examples/request.personal-memory-read.json @@ -1,19 +1,19 @@ -{ - "actor": "developer", - "purpose": "personal_memory", - "action": "read_context", - "region": "NZ", - "model_id": "local/lumynax", - "data_classes": [ - "personal", - "preferences" - ], - "tool_name": "personal_profile_reader", - "writes_files": false, - "exports_data": false, - "trains_model": false, - "human_approved": false, - "personal_detail_level": "pseudonymous", - "consent_scope": "personal_memory", - "requested_retention_days": 7 -} +{ + "actor": "developer", + "purpose": "personal_memory", + "action": "read_context", + "region": "NZ", + "model_id": "local/lumynax", + "data_classes": [ + "personal", + "preferences" + ], + "tool_name": "personal_profile_reader", + "writes_files": false, + "exports_data": false, + "trains_model": false, + "human_approved": false, + "personal_detail_level": "pseudonymous", + "consent_scope": "personal_memory", + "requested_retention_days": 7 +} diff --git a/sovereigncode/integrations/opencode-compatible-provider.md b/sovereigncode/integrations/opencode-compatible-provider.md index 9877da8213a7f0b489aa9fe086d8d7f593323893..06ca743666af12417a425086c987c4c952fb207a 100644 --- a/sovereigncode/integrations/opencode-compatible-provider.md +++ b/sovereigncode/integrations/opencode-compatible-provider.md @@ -1,96 +1,96 @@ -# OpenCode-Compatible Provider Integration - -## Goal - -Make AbteeX SovereignCode usable from OpenCode and similar coding agents without -requiring those tools to understand Data Capsules directly. - -The integration shape is: - -```text -OpenCode - -> OpenAI-compatible provider config - -> MaramaRoute gateway `/v1` - -> SovereignCode policy and tool broker - -> LumynaX model runtime -``` - -## Current Compatibility Target - -OpenCode supports custom OpenAI-compatible providers through -`@ai-sdk/openai-compatible` and a provider `baseURL`. OpenRouter exposes an -OpenAI-like chat endpoint at `/api/v1/chat/completions`, with normalized request -and response payloads. MaramaRoute should therefore expose: - -- `GET /v1/models` -- `POST /v1/chat/completions` -- `POST /v1/route` -- `GET /v1/route/{decision_id}` - -References checked on 2026-05-17: - -- https://opencode.ai/docs/providers -- https://openrouter.ai/docs/api-reference/overview/ -- https://openrouter.ai/docs/api-reference/chat-completion - -## OpenCode Provider Config - -Use `examples/opencode.marama-route.json` as the project-local provider file. - -The important fields are: - -| Field | Value | -| --- | --- | -| `provider.abteex-marama.npm` | `@ai-sdk/openai-compatible` | -| `provider.abteex-marama.options.baseURL` | Local or hosted MaramaRoute `/v1` URL | -| `provider.abteex-marama.options.apiKey` | Environment backed key | -| `provider.abteex-marama.models` | LumynaX model aliases exposed by MaramaRoute | - -## SovereignCode Responsibilities - -OpenCode sends a normal chat request. SovereignCode and MaramaRoute add: - -- capsule resolution from workspace policy files -- purpose and personal-detail checks before prompt assembly -- model routing based on residency, modality, task, and sensitivity -- visible approval gates before file writes, shell commands, network export, or commit -- audit records for policy decisions and route decisions - -## Workspace Files - -A governed workspace should carry: - -```text -.sovereigncode/ - capsule.json - tenant-policy.yaml - approvals/ - audit/ -opencode.json -``` - -The agent can start with `capsule.json` and `opencode.json`. The full tool -broker can add approvals and audit persistence in the next build stage. - -## Minimum Viable Flow - -1. User opens a project in OpenCode. -2. OpenCode uses the `abteex-marama` provider. -3. MaramaRoute dry-runs the chat payload and selects a LumynaX model. -4. SovereignCode checks the workspace Data Capsule before exposing context. -5. The coding agent proposes a plan. -6. File writes require a visible diff and an audit record. -7. Shell, network, commit, and publish actions require explicit approval. - -## Similar Clients - -Any client that can point at an OpenAI-compatible endpoint should use the same -gateway: - -| Client type | Expected integration | -| --- | --- | -| OpenCode | `opencode.json` custom provider | -| Continue-style IDE assistant | OpenAI-compatible base URL and model ids | -| Aider-style terminal assistant | OpenAI-compatible base URL and key | -| Internal agent runner | Direct `/v1/route` and `/v1/chat/completions` calls | -| Browser console | Same API behind tenant auth | +# OpenCode-Compatible Provider Integration + +## Goal + +Make AbteeX SovereignCode usable from OpenCode and similar coding agents without +requiring those tools to understand Data Capsules directly. + +The integration shape is: + +```text +OpenCode + -> OpenAI-compatible provider config + -> MaramaRoute gateway `/v1` + -> SovereignCode policy and tool broker + -> LumynaX model runtime +``` + +## Current Compatibility Target + +OpenCode supports custom OpenAI-compatible providers through +`@ai-sdk/openai-compatible` and a provider `baseURL`. OpenRouter exposes an +OpenAI-like chat endpoint at `/api/v1/chat/completions`, with normalized request +and response payloads. MaramaRoute should therefore expose: + +- `GET /v1/models` +- `POST /v1/chat/completions` +- `POST /v1/route` +- `GET /v1/route/{decision_id}` + +References checked on 2026-05-17: + +- https://opencode.ai/docs/providers +- https://openrouter.ai/docs/api-reference/overview/ +- https://openrouter.ai/docs/api-reference/chat-completion + +## OpenCode Provider Config + +Use `examples/opencode.marama-route.json` as the project-local provider file. + +The important fields are: + +| Field | Value | +| --- | --- | +| `provider.abteex-marama.npm` | `@ai-sdk/openai-compatible` | +| `provider.abteex-marama.options.baseURL` | Local or hosted MaramaRoute `/v1` URL | +| `provider.abteex-marama.options.apiKey` | Environment backed key | +| `provider.abteex-marama.models` | LumynaX model aliases exposed by MaramaRoute | + +## SovereignCode Responsibilities + +OpenCode sends a normal chat request. SovereignCode and MaramaRoute add: + +- capsule resolution from workspace policy files +- purpose and personal-detail checks before prompt assembly +- model routing based on residency, modality, task, and sensitivity +- visible approval gates before file writes, shell commands, network export, or commit +- audit records for policy decisions and route decisions + +## Workspace Files + +A governed workspace should carry: + +```text +.sovereigncode/ + capsule.json + tenant-policy.yaml + approvals/ + audit/ +opencode.json +``` + +The agent can start with `capsule.json` and `opencode.json`. The full tool +broker can add approvals and audit persistence in the next build stage. + +## Minimum Viable Flow + +1. User opens a project in OpenCode. +2. OpenCode uses the `abteex-marama` provider. +3. MaramaRoute dry-runs the chat payload and selects a LumynaX model. +4. SovereignCode checks the workspace Data Capsule before exposing context. +5. The coding agent proposes a plan. +6. File writes require a visible diff and an audit record. +7. Shell, network, commit, and publish actions require explicit approval. + +## Similar Clients + +Any client that can point at an OpenAI-compatible endpoint should use the same +gateway: + +| Client type | Expected integration | +| --- | --- | +| OpenCode | `opencode.json` custom provider | +| Continue-style IDE assistant | OpenAI-compatible base URL and model ids | +| Aider-style terminal assistant | OpenAI-compatible base URL and key | +| Internal agent runner | Direct `/v1/route` and `/v1/chat/completions` calls | +| Browser console | Same API behind tenant auth | diff --git a/sovereigncode/integrations/opencode-provider.json b/sovereigncode/integrations/opencode-provider.json index 02b278a31d185e8e620ccbdc65576fb437424288..05c32a5c558c8aaaff9a8dac194e099da29e7391 100644 --- a/sovereigncode/integrations/opencode-provider.json +++ b/sovereigncode/integrations/opencode-provider.json @@ -1,28 +1,28 @@ -{ - "$schema": "https://opencode.ai/config.json", - "provider": { - "abteex-marama": { - "npm": "@ai-sdk/openai-compatible", - "name": "AbteeX MaramaRoute", - "options": { - "baseURL": "http://127.0.0.1:8787/v1", - "apiKey": "{env:ABTEEX_MARAMA_API_KEY}", - "headers": { - "X-AbteeX-Route-Jurisdiction": "NZ", - "X-AbteeX-Route-Sensitivity": "restricted" - } - }, - "models": { - "lumynax/auto": { - "name": "LumynaX Auto" - }, - "lumynax/code": { - "name": "LumynaX Code" - }, - "lumynax/local": { - "name": "LumynaX Local" - } - } - } - } -} +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "abteex-marama": { + "npm": "@ai-sdk/openai-compatible", + "name": "AbteeX MaramaRoute", + "options": { + "baseURL": "http://127.0.0.1:8787/v1", + "apiKey": "{env:ABTEEX_MARAMA_API_KEY}", + "headers": { + "X-AbteeX-Route-Jurisdiction": "NZ", + "X-AbteeX-Route-Sensitivity": "restricted" + } + }, + "models": { + "lumynax/auto": { + "name": "LumynaX Auto" + }, + "lumynax/code": { + "name": "LumynaX Code" + }, + "lumynax/local": { + "name": "LumynaX Local" + } + } + } + } +} diff --git a/sovereigncode/ledger.py b/sovereigncode/ledger.py new file mode 100644 index 0000000000000000000000000000000000000000..ea315a9f9db990c30ef2789241fc8d9b0ba7909b --- /dev/null +++ b/sovereigncode/ledger.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import hashlib +import json +import threading +from dataclasses import dataclass, field +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + + +def default_ledger_path() -> Path: + return Path.cwd() / ".sovereigncode" / "audit.jsonl" + + +@dataclass(slots=True) +class AuditLedger: + path: Path + _lock: threading.Lock = field(init=False, repr=False) + + def __post_init__(self) -> None: + self._lock = threading.Lock() + + def append(self, event: str, payload: dict[str, Any]) -> dict[str, Any]: + with self._lock: + self.path.parent.mkdir(parents=True, exist_ok=True) + sequence = self._line_count() + 1 + record = { + "ledger_sequence": sequence, + "ledger_timestamp": datetime.now(UTC).isoformat(), + "event": event, + "payload": payload, + } + record["ledger_id"] = self._digest(record) + with self.path.open("a", encoding="utf-8", newline="\n") as stream: + stream.write(json.dumps(record, sort_keys=True, separators=(",", ":"))) + stream.write("\n") + return {**record, "ledger_path": str(self.path)} + + def tail(self, limit: int = 50) -> list[dict[str, Any]]: + if not self.path.exists(): + return [] + lines = self.path.read_text(encoding="utf-8-sig").splitlines() + records: list[dict[str, Any]] = [] + for line in lines[-max(1, limit) :]: + try: + payload = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(payload, dict): + payload["ledger_path"] = str(self.path) + records.append(payload) + return records + + def _line_count(self) -> int: + if not self.path.exists(): + return 0 + with self.path.open("r", encoding="utf-8-sig") as stream: + return sum(1 for line in stream if line.strip()) + + @staticmethod + def _digest(record: dict[str, Any]) -> str: + raw = json.dumps(record, sort_keys=True, default=str).encode("utf-8") + return f"sc-ledger-{hashlib.sha256(raw).hexdigest()[:24]}" diff --git a/sovereigncode/platform.py b/sovereigncode/platform.py index 085395e78b77836a1bb0a8b51a9466d00c09041c..34f9d132268af6754830f1a7621f0971752b0ecd 100644 --- a/sovereigncode/platform.py +++ b/sovereigncode/platform.py @@ -1,275 +1,275 @@ -from __future__ import annotations - -from typing import Any - -from .audit import build_audit_record -from .policy import DataCapsule, SovereignRequest, SovereigntyPolicyEngine - -TOOL_SCENARIOS: tuple[dict[str, Any], ...] = ( - { - "name": "Read workspace", - "action": "read_context", - "tool_name": "workspace_reader", - "writes_files": False, - "exports_data": False, - "trains_model": False, - "human_approved": False, - }, - { - "name": "Write file", - "action": "write_file", - "tool_name": "file_editor", - "writes_files": True, - "exports_data": False, - "trains_model": False, - "human_approved": False, - }, - { - "name": "Approved shell", - "action": "execute_shell", - "tool_name": "test_runner", - "writes_files": False, - "exports_data": False, - "trains_model": False, - "human_approved": True, - }, - { - "name": "Network export", - "action": "network_export", - "tool_name": "external_api", - "writes_files": False, - "exports_data": True, - "trains_model": False, - "human_approved": True, - }, - { - "name": "Model training", - "action": "train_model", - "tool_name": "trainer", - "writes_files": False, - "exports_data": False, - "trains_model": True, - "human_approved": True, - }, - { - "name": "Publish commit", - "action": "commit", - "tool_name": "git", - "writes_files": False, - "exports_data": True, - "trains_model": False, - "human_approved": True, - }, -) - - -def build_policy_matrix( - capsule_payload: dict[str, Any], - request_payload: dict[str, Any], - scenarios: list[dict[str, Any]] | None = None, -) -> dict[str, Any]: - capsule = DataCapsule.from_payload(capsule_payload) - engine = SovereigntyPolicyEngine() - rows = [] - for scenario in scenarios or [dict(item) for item in TOOL_SCENARIOS]: - merged = dict(request_payload) - merged.update({key: value for key, value in scenario.items() if key != "name"}) - request = SovereignRequest.from_payload(merged) - decision = engine.evaluate(capsule, request) - audit = build_audit_record(capsule, request, decision) - rows.append( - { - "name": scenario.get("name", request.action), - "allowed": decision.allowed, - "action": request.action, - "tool_name": request.tool_name, - "writes_files": request.writes_files, - "exports_data": request.exports_data, - "trains_model": request.trains_model, - "human_approved": request.human_approved, - "reason_count": len(decision.reasons), - "obligation_count": len(decision.obligations), - "reasons": list(decision.reasons), - "obligations": list(decision.obligations), - "audit_hash": audit.request_hash, - }, - ) - return { - "ok": True, - "capsule_id": capsule.capsule_id, - "rows": rows, - "allowed_count": sum(1 for row in rows if row["allowed"]), - "blocked_count": sum(1 for row in rows if not row["allowed"]), - } - - -def check_tool_request( - capsule_payload: dict[str, Any], - request_payload: dict[str, Any], - tool_payload: dict[str, Any], -) -> dict[str, Any]: - merged = dict(request_payload) - merged.update(tool_payload) - capsule = DataCapsule.from_payload(capsule_payload) - request = SovereignRequest.from_payload(merged) - decision = SovereigntyPolicyEngine().evaluate(capsule, request) - audit = build_audit_record(capsule, request, decision) - return { - "ok": decision.allowed, - "tool_name": request.tool_name, - "action": request.action, - "decision": decision.to_dict(), - "audit_record": audit.to_dict(), - "operator_gate": build_operator_gate(decision.to_dict(), request.to_dict()), - } - - -def build_operator_gate(decision: dict[str, Any], request: dict[str, Any]) -> dict[str, Any]: - destructive = bool( - request.get("writes_files") - or request.get("exports_data") - or request.get("trains_model") - or request.get("action") in {"execute_shell", "publish", "commit", "network_export"} - ) - return { - "requires_human_review": destructive or not decision.get("allowed", False), - "requires_visible_diff": bool(request.get("writes_files")), - "requires_export_manifest": bool(request.get("exports_data")), - "requires_training_consent": bool(request.get("trains_model")), - "next_gate": _next_gate(decision, request), - } - - -def build_turn_brief(plan: dict[str, Any]) -> dict[str, Any]: - route = plan.get("route_decision") or {} - selected = route.get("selected_model") or {} - grants = plan.get("tool_grants") or [] - blocked_grants = [grant for grant in grants if not grant.get("allowed")] - return { - "allowed": bool(plan.get("allowed")), - "selected_model": selected.get("model_id"), - "runtime": selected.get("runtime"), - "tool_grants": len(grants), - "blocked_tool_grants": len(blocked_grants), - "obligation_count": len(plan.get("obligations") or []), - "blocked_reasons": plan.get("blocked_reasons") or [], - "operator_checklist": build_operator_checklist(plan), - } - - -def build_operator_checklist(plan: dict[str, Any]) -> list[dict[str, Any]]: - allowed = bool(plan.get("allowed")) - obligations = set(plan.get("obligations") or []) - route = plan.get("route_decision") or {} - selected = route.get("selected_model") - return [ - { - "item": "policy_decision", - "status": "pass" if allowed else "blocked", - "detail": "Data Capsule policy permits the request" if allowed else "Policy blocked the request", - }, - { - "item": "model_route", - "status": "pass" if selected else "blocked", - "detail": selected.get("model_id") if isinstance(selected, dict) else "No eligible model", - }, - { - "item": "audit_record", - "status": "required", - "detail": "Immutable audit record must be persisted before external effects", - }, - { - "item": "visible_diff", - "status": "required" if "show_diff_before_write_or_commit" in obligations else "not_required", - "detail": "Show file diff before writes or commits", - }, - { - "item": "resident_runtime", - "status": "required" if "route_only_to_resident_runtime" in obligations else "not_required", - "detail": "Route high-impact data only to approved resident runtime", - }, - ] - - -def build_opencode_workspace_config( - *, - base_url: str = "http://127.0.0.1:8787/v1", - provider_id: str = "abteex-marama", - model: str = "lumynax-infused-qwen3-coder-30b-a3b-gguf", -) -> dict[str, Any]: - return { - "$schema": "https://opencode.ai/config.json", - "provider": { - provider_id: { - "name": "AbteeX SovereignCode via MaramaRoute", - "npm": "@ai-sdk/openai-compatible", - "options": { - "baseURL": base_url, - "apiKey": "${ABTEEX_MARAMA_API_KEY:-local-dev}", - }, - "models": { - model: { - "name": model, - "attachment": False, - "reasoning": True, - }, - }, - }, - }, - "model": f"{provider_id}/{model}", - "sovereigncode": { - "capsule_file": "products/abx-sovereigncode/examples/capsule.restricted-nz-code.json", - "audit_ledger": ".sovereigncode/audit.jsonl", - "require_human_review_for": ["write_files", "execute_shell", "network_export", "commit"], - }, - } - - -def build_capsule_summary(capsule_payload: dict[str, Any]) -> dict[str, Any]: - capsule = DataCapsule.from_payload(capsule_payload) - return { - "capsule_id": capsule.capsule_id, - "jurisdiction": capsule.jurisdiction, - "sensitivity": capsule.sensitivity, - "resident_regions": list(capsule.resident_regions), - "allowed_purposes": list(capsule.allowed_purposes), - "denied_purposes": list(capsule.denied_purposes), - "retention_days": capsule.retention_days, - "export_allowed": capsule.export_allowed, - "training_allowed": capsule.training_allowed, - "personal_detail_level": capsule.personal_detail_level, - "risk_flags": _capsule_risk_flags(capsule), - } - - -def tool_scenarios() -> list[dict[str, Any]]: - return [dict(item) for item in TOOL_SCENARIOS] - - -def _capsule_risk_flags(capsule: DataCapsule) -> list[str]: - flags = [] - if capsule.sensitivity in {"personal", "restricted", "health", "iwi", "taonga"}: - flags.append("high_impact_sensitivity") - if not capsule.export_allowed: - flags.append("export_blocked") - if not capsule.training_allowed: - flags.append("training_blocked") - if capsule.retention_days <= 14: - flags.append("short_retention") - if capsule.personal_detail_level not in {"none", "anonymous"}: - flags.append("personal_detail_controls") - return flags - - -def _next_gate(decision: dict[str, Any], request: dict[str, Any]) -> str: - if not decision.get("allowed", False): - return "revise_request_or_capsule" - if request.get("writes_files"): - return "show_diff_before_write" - if request.get("exports_data"): - return "attach_export_manifest" - if request.get("trains_model"): - return "attach_training_consent" - if request.get("action") in {"execute_shell", "commit"}: - return "human_approval" - return "execute_with_audit" +from __future__ import annotations + +from typing import Any + +from .audit import build_audit_record +from .policy import DataCapsule, SovereignRequest, SovereigntyPolicyEngine + +TOOL_SCENARIOS: tuple[dict[str, Any], ...] = ( + { + "name": "Read workspace", + "action": "read_context", + "tool_name": "workspace_reader", + "writes_files": False, + "exports_data": False, + "trains_model": False, + "human_approved": False, + }, + { + "name": "Write file", + "action": "write_file", + "tool_name": "file_editor", + "writes_files": True, + "exports_data": False, + "trains_model": False, + "human_approved": False, + }, + { + "name": "Approved shell", + "action": "execute_shell", + "tool_name": "test_runner", + "writes_files": False, + "exports_data": False, + "trains_model": False, + "human_approved": True, + }, + { + "name": "Network export", + "action": "network_export", + "tool_name": "external_api", + "writes_files": False, + "exports_data": True, + "trains_model": False, + "human_approved": True, + }, + { + "name": "Model training", + "action": "train_model", + "tool_name": "trainer", + "writes_files": False, + "exports_data": False, + "trains_model": True, + "human_approved": True, + }, + { + "name": "Publish commit", + "action": "commit", + "tool_name": "git", + "writes_files": False, + "exports_data": True, + "trains_model": False, + "human_approved": True, + }, +) + + +def build_policy_matrix( + capsule_payload: dict[str, Any], + request_payload: dict[str, Any], + scenarios: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + capsule = DataCapsule.from_payload(capsule_payload) + engine = SovereigntyPolicyEngine() + rows = [] + for scenario in scenarios or [dict(item) for item in TOOL_SCENARIOS]: + merged = dict(request_payload) + merged.update({key: value for key, value in scenario.items() if key != "name"}) + request = SovereignRequest.from_payload(merged) + decision = engine.evaluate(capsule, request) + audit = build_audit_record(capsule, request, decision) + rows.append( + { + "name": scenario.get("name", request.action), + "allowed": decision.allowed, + "action": request.action, + "tool_name": request.tool_name, + "writes_files": request.writes_files, + "exports_data": request.exports_data, + "trains_model": request.trains_model, + "human_approved": request.human_approved, + "reason_count": len(decision.reasons), + "obligation_count": len(decision.obligations), + "reasons": list(decision.reasons), + "obligations": list(decision.obligations), + "audit_hash": audit.request_hash, + }, + ) + return { + "ok": True, + "capsule_id": capsule.capsule_id, + "rows": rows, + "allowed_count": sum(1 for row in rows if row["allowed"]), + "blocked_count": sum(1 for row in rows if not row["allowed"]), + } + + +def check_tool_request( + capsule_payload: dict[str, Any], + request_payload: dict[str, Any], + tool_payload: dict[str, Any], +) -> dict[str, Any]: + merged = dict(request_payload) + merged.update(tool_payload) + capsule = DataCapsule.from_payload(capsule_payload) + request = SovereignRequest.from_payload(merged) + decision = SovereigntyPolicyEngine().evaluate(capsule, request) + audit = build_audit_record(capsule, request, decision) + return { + "ok": decision.allowed, + "tool_name": request.tool_name, + "action": request.action, + "decision": decision.to_dict(), + "audit_record": audit.to_dict(), + "operator_gate": build_operator_gate(decision.to_dict(), request.to_dict()), + } + + +def build_operator_gate(decision: dict[str, Any], request: dict[str, Any]) -> dict[str, Any]: + destructive = bool( + request.get("writes_files") + or request.get("exports_data") + or request.get("trains_model") + or request.get("action") in {"execute_shell", "publish", "commit", "network_export"} + ) + return { + "requires_human_review": destructive or not decision.get("allowed", False), + "requires_visible_diff": bool(request.get("writes_files")), + "requires_export_manifest": bool(request.get("exports_data")), + "requires_training_consent": bool(request.get("trains_model")), + "next_gate": _next_gate(decision, request), + } + + +def build_turn_brief(plan: dict[str, Any]) -> dict[str, Any]: + route = plan.get("route_decision") or {} + selected = route.get("selected_model") or {} + grants = plan.get("tool_grants") or [] + blocked_grants = [grant for grant in grants if not grant.get("allowed")] + return { + "allowed": bool(plan.get("allowed")), + "selected_model": selected.get("model_id"), + "runtime": selected.get("runtime"), + "tool_grants": len(grants), + "blocked_tool_grants": len(blocked_grants), + "obligation_count": len(plan.get("obligations") or []), + "blocked_reasons": plan.get("blocked_reasons") or [], + "operator_checklist": build_operator_checklist(plan), + } + + +def build_operator_checklist(plan: dict[str, Any]) -> list[dict[str, Any]]: + allowed = bool(plan.get("allowed")) + obligations = set(plan.get("obligations") or []) + route = plan.get("route_decision") or {} + selected = route.get("selected_model") + return [ + { + "item": "policy_decision", + "status": "pass" if allowed else "blocked", + "detail": "Data Capsule policy permits the request" if allowed else "Policy blocked the request", + }, + { + "item": "model_route", + "status": "pass" if selected else "blocked", + "detail": selected.get("model_id") if isinstance(selected, dict) else "No eligible model", + }, + { + "item": "audit_record", + "status": "required", + "detail": "Immutable audit record must be persisted before external effects", + }, + { + "item": "visible_diff", + "status": "required" if "show_diff_before_write_or_commit" in obligations else "not_required", + "detail": "Show file diff before writes or commits", + }, + { + "item": "resident_runtime", + "status": "required" if "route_only_to_resident_runtime" in obligations else "not_required", + "detail": "Route high-impact data only to approved resident runtime", + }, + ] + + +def build_opencode_workspace_config( + *, + base_url: str = "http://127.0.0.1:8787/v1", + provider_id: str = "abteex-marama", + model: str = "lumynax-infused-qwen3-coder-30b-a3b-gguf", +) -> dict[str, Any]: + return { + "$schema": "https://opencode.ai/config.json", + "provider": { + provider_id: { + "name": "AbteeX SovereignCode via MaramaRoute", + "npm": "@ai-sdk/openai-compatible", + "options": { + "baseURL": base_url, + "apiKey": "${ABTEEX_MARAMA_API_KEY:-local-dev}", + }, + "models": { + model: { + "name": model, + "attachment": False, + "reasoning": True, + }, + }, + }, + }, + "model": f"{provider_id}/{model}", + "sovereigncode": { + "capsule_file": "products/abx-sovereigncode/examples/capsule.restricted-nz-code.json", + "audit_ledger": ".sovereigncode/audit.jsonl", + "require_human_review_for": ["write_files", "execute_shell", "network_export", "commit"], + }, + } + + +def build_capsule_summary(capsule_payload: dict[str, Any]) -> dict[str, Any]: + capsule = DataCapsule.from_payload(capsule_payload) + return { + "capsule_id": capsule.capsule_id, + "jurisdiction": capsule.jurisdiction, + "sensitivity": capsule.sensitivity, + "resident_regions": list(capsule.resident_regions), + "allowed_purposes": list(capsule.allowed_purposes), + "denied_purposes": list(capsule.denied_purposes), + "retention_days": capsule.retention_days, + "export_allowed": capsule.export_allowed, + "training_allowed": capsule.training_allowed, + "personal_detail_level": capsule.personal_detail_level, + "risk_flags": _capsule_risk_flags(capsule), + } + + +def tool_scenarios() -> list[dict[str, Any]]: + return [dict(item) for item in TOOL_SCENARIOS] + + +def _capsule_risk_flags(capsule: DataCapsule) -> list[str]: + flags = [] + if capsule.sensitivity in {"personal", "restricted", "health", "iwi", "taonga"}: + flags.append("high_impact_sensitivity") + if not capsule.export_allowed: + flags.append("export_blocked") + if not capsule.training_allowed: + flags.append("training_blocked") + if capsule.retention_days <= 14: + flags.append("short_retention") + if capsule.personal_detail_level not in {"none", "anonymous"}: + flags.append("personal_detail_controls") + return flags + + +def _next_gate(decision: dict[str, Any], request: dict[str, Any]) -> str: + if not decision.get("allowed", False): + return "revise_request_or_capsule" + if request.get("writes_files"): + return "show_diff_before_write" + if request.get("exports_data"): + return "attach_export_manifest" + if request.get("trains_model"): + return "attach_training_consent" + if request.get("action") in {"execute_shell", "commit"}: + return "human_approval" + return "execute_with_audit" diff --git a/sovereigncode/policy-packs/nz-personal-sovereignty.yaml b/sovereigncode/policy-packs/nz-personal-sovereignty.yaml index 5da0f89ff04edf244088b0d18d33c0286f819264..96ec2bf8a8cbd94653d8a19b72b6886f20398a54 100644 --- a/sovereigncode/policy-packs/nz-personal-sovereignty.yaml +++ b/sovereigncode/policy-packs/nz-personal-sovereignty.yaml @@ -1,59 +1,59 @@ -policy_id: abx-sovereigncode-nz-personal-sovereignty-v0 -jurisdiction: NZ -purpose: governed personal, workspace, and community-context coding assistance -default_residency: - - NZ -allowed_personal_detail_levels: - - none - - anonymous - - pseudonymous - - identifiable - - sensitive_identifiable -default_personal_detail_level: pseudonymous -consent_scopes: - coding_assistance: - allowed_actions: - - read_context - - propose_patch - - generate_tests - blocked_actions_without_approval: - - write_file - - execute_shell - - commit - - publish - - network_export - personal_memory: - allowed_actions: - - read_preference - - summarise_profile - blocked_actions_without_approval: - - export_profile - - train_adapter - - share_with_third_party -retention_defaults: - restricted_code_days: 14 - personal_trace_days: 7 - audit_record_days: 365 -data_subject_rights: - - access - - correction - - deletion_request - - processing_objection -obligations: - - write_immutable_audit_record - - minimise_personal_detail_in_prompt - - keep_personal_trace_inside_capsule_retention - - show_diff_before_write_or_commit - - route_only_to_resident_runtime - - require_human_review_for_external_effects -model_rules: - high_impact_requires_lumynax_or_local: true - restricted_requires_nz_residency: true - public_may_route_to_approved_global: true -export_rules: - default_export_allowed: false - require_export_manifest: true - require_named_recipient: true -training_rules: - default_training_allowed: false - require_explicit_capsule_training_allowed: true +policy_id: abx-sovereigncode-nz-personal-sovereignty-v0 +jurisdiction: NZ +purpose: governed personal, workspace, and community-context coding assistance +default_residency: + - NZ +allowed_personal_detail_levels: + - none + - anonymous + - pseudonymous + - identifiable + - sensitive_identifiable +default_personal_detail_level: pseudonymous +consent_scopes: + coding_assistance: + allowed_actions: + - read_context + - propose_patch + - generate_tests + blocked_actions_without_approval: + - write_file + - execute_shell + - commit + - publish + - network_export + personal_memory: + allowed_actions: + - read_preference + - summarise_profile + blocked_actions_without_approval: + - export_profile + - train_adapter + - share_with_third_party +retention_defaults: + restricted_code_days: 14 + personal_trace_days: 7 + audit_record_days: 365 +data_subject_rights: + - access + - correction + - deletion_request + - processing_objection +obligations: + - write_immutable_audit_record + - minimise_personal_detail_in_prompt + - keep_personal_trace_inside_capsule_retention + - show_diff_before_write_or_commit + - route_only_to_resident_runtime + - require_human_review_for_external_effects +model_rules: + high_impact_requires_lumynax_or_local: true + restricted_requires_nz_residency: true + public_may_route_to_approved_global: true +export_rules: + default_export_allowed: false + require_export_manifest: true + require_named_recipient: true +training_rules: + default_training_allowed: false + require_explicit_capsule_training_allowed: true diff --git a/sovereigncode/policy.py b/sovereigncode/policy.py index 112f8fdeec057aa637b23140bce8889ddb779ce4..6859b124d1e2aba095d9cc01542849f9962fddfb 100644 --- a/sovereigncode/policy.py +++ b/sovereigncode/policy.py @@ -1,290 +1,290 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -HIGH_IMPACT_SENSITIVITY = frozenset({"personal", "restricted", "health", "iwi", "taonga"}) -DESTRUCTIVE_ACTIONS = frozenset({"delete_file", "execute_shell", "network_export", "publish", "commit"}) -PERSONAL_DETAIL_LEVELS = { - "none": 0, - "anonymous": 1, - "pseudonymous": 2, - "identifiable": 3, - "sensitive_identifiable": 4, -} - - -def _tuple_of_text(value: object, *, default: tuple[str, ...] = ()) -> tuple[str, ...]: - if value in (None, ""): - return default - if isinstance(value, str): - return (value,) - if isinstance(value, (list, tuple, set)): - return tuple(str(item).strip() for item in value if str(item).strip()) - return (str(value).strip(),) - - -def _normal(value: str) -> str: - return value.strip().lower().replace(" ", "_").replace("-", "_") - - -def _personal_detail_rank(value: str) -> int: - return PERSONAL_DETAIL_LEVELS.get(_normal(value), 0) - - -@dataclass(frozen=True, slots=True) -class DataCapsule: - """Policy wrapper for a governed data subject, dataset, or workspace.""" - - capsule_id: str - subject_id: str - jurisdiction: str = "NZ" - sensitivity: str = "internal" - allowed_purposes: tuple[str, ...] = ("inference", "coding_assistance") - denied_purposes: tuple[str, ...] = () - resident_regions: tuple[str, ...] = ("NZ",) - data_classes: tuple[str, ...] = ("source_code",) - retention_days: int = 30 - export_allowed: bool = False - training_allowed: bool = False - personal_detail_level: str = "none" - consent_scopes: tuple[str, ...] = () - data_subject_rights: tuple[str, ...] = ( - "access", - "correction", - "deletion_request", - "processing_objection", - ) - revoked: bool = False - schema_context: str = "https://schema.org" - consent_record: str = "" - metadata: dict[str, Any] = field(default_factory=dict) - - @classmethod - def from_payload(cls, payload: dict[str, Any]) -> DataCapsule: - return cls( - capsule_id=str(payload.get("capsule_id") or payload.get("id") or ""), - subject_id=str(payload.get("subject_id") or payload.get("subject") or ""), - jurisdiction=str(payload.get("jurisdiction") or "NZ"), - sensitivity=_normal(str(payload.get("sensitivity") or "internal")), - allowed_purposes=tuple(_normal(item) for item in _tuple_of_text(payload.get("allowed_purposes"), default=("inference",))), - denied_purposes=tuple(_normal(item) for item in _tuple_of_text(payload.get("denied_purposes"))), - resident_regions=tuple(str(item).upper() for item in _tuple_of_text(payload.get("resident_regions"), default=("NZ",))), - data_classes=tuple(_normal(item) for item in _tuple_of_text(payload.get("data_classes"), default=("source_code",))), - retention_days=int(payload.get("retention_days") or 30), - export_allowed=bool(payload.get("export_allowed", False)), - training_allowed=bool(payload.get("training_allowed", False)), - personal_detail_level=_normal( - str(payload.get("personal_detail_level") or "none"), - ), - consent_scopes=tuple( - _normal(item) for item in _tuple_of_text(payload.get("consent_scopes")) - ), - data_subject_rights=tuple( - _normal(item) - for item in _tuple_of_text( - payload.get("data_subject_rights"), - default=( - "access", - "correction", - "deletion_request", - "processing_objection", - ), - ) - ), - revoked=bool(payload.get("revoked", False)), - schema_context=str(payload.get("schema_context") or "https://schema.org"), - consent_record=str(payload.get("consent_record") or ""), - metadata=dict(payload.get("metadata") or {}), - ) - - def to_dict(self) -> dict[str, Any]: - return { - "capsule_id": self.capsule_id, - "subject_id": self.subject_id, - "jurisdiction": self.jurisdiction, - "sensitivity": self.sensitivity, - "allowed_purposes": list(self.allowed_purposes), - "denied_purposes": list(self.denied_purposes), - "resident_regions": list(self.resident_regions), - "data_classes": list(self.data_classes), - "retention_days": self.retention_days, - "export_allowed": self.export_allowed, - "training_allowed": self.training_allowed, - "personal_detail_level": self.personal_detail_level, - "consent_scopes": list(self.consent_scopes), - "data_subject_rights": list(self.data_subject_rights), - "revoked": self.revoked, - "schema_context": self.schema_context, - "consent_record": self.consent_record, - "metadata": dict(self.metadata), - } - - -@dataclass(frozen=True, slots=True) -class SovereignRequest: - actor: str - purpose: str - action: str - region: str = "NZ" - model_id: str = "local/lumynax" - data_classes: tuple[str, ...] = ("source_code",) - tool_name: str = "" - writes_files: bool = False - exports_data: bool = False - trains_model: bool = False - human_approved: bool = False - personal_detail_level: str = "none" - consent_scope: str = "" - requested_retention_days: int | None = None - metadata: dict[str, Any] = field(default_factory=dict) - - @classmethod - def from_payload(cls, payload: dict[str, Any]) -> SovereignRequest: - return cls( - actor=str(payload.get("actor") or "unknown"), - purpose=_normal(str(payload.get("purpose") or "inference")), - action=_normal(str(payload.get("action") or "read_context")), - region=str(payload.get("region") or "NZ").upper(), - model_id=str(payload.get("model_id") or "local/lumynax"), - data_classes=tuple(_normal(item) for item in _tuple_of_text(payload.get("data_classes"), default=("source_code",))), - tool_name=str(payload.get("tool_name") or ""), - writes_files=bool(payload.get("writes_files", False)), - exports_data=bool(payload.get("exports_data", False)), - trains_model=bool(payload.get("trains_model", False)), - human_approved=bool(payload.get("human_approved", False)), - personal_detail_level=_normal( - str(payload.get("personal_detail_level") or "none"), - ), - consent_scope=_normal(str(payload.get("consent_scope") or "")), - requested_retention_days=( - int(payload["requested_retention_days"]) - if payload.get("requested_retention_days") is not None - else None - ), - metadata=dict(payload.get("metadata") or {}), - ) - - def to_dict(self) -> dict[str, Any]: - return { - "actor": self.actor, - "purpose": self.purpose, - "action": self.action, - "region": self.region, - "model_id": self.model_id, - "data_classes": list(self.data_classes), - "tool_name": self.tool_name, - "writes_files": self.writes_files, - "exports_data": self.exports_data, - "trains_model": self.trains_model, - "human_approved": self.human_approved, - "personal_detail_level": self.personal_detail_level, - "consent_scope": self.consent_scope, - "requested_retention_days": self.requested_retention_days, - "metadata": dict(self.metadata), - } - - -@dataclass(frozen=True, slots=True) -class PolicyDecision: - allowed: bool - reasons: tuple[str, ...] - obligations: tuple[str, ...] - audit_tags: tuple[str, ...] - - def to_dict(self) -> dict[str, Any]: - return { - "allowed": self.allowed, - "reasons": list(self.reasons), - "obligations": list(self.obligations), - "audit_tags": list(self.audit_tags), - } - - -class SovereigntyPolicyEngine: - """Deterministic policy decision point for AbteeX SovereignCode.""" - - def evaluate(self, capsule: DataCapsule, request: SovereignRequest) -> PolicyDecision: - reasons: list[str] = [] - obligations: list[str] = [ - "write_immutable_audit_record", - f"retain_trace_no_more_than_{capsule.retention_days}_days", - "preserve_capsule_id_in_agent_trace", - ] - audit_tags: list[str] = [ - f"jurisdiction:{capsule.jurisdiction.upper()}", - f"sensitivity:{capsule.sensitivity}", - f"purpose:{request.purpose}", - ] - - if capsule.revoked: - reasons.append("capsule consent has been revoked") - if request.purpose in capsule.denied_purposes: - reasons.append(f"purpose `{request.purpose}` is explicitly denied") - if request.purpose not in capsule.allowed_purposes: - reasons.append(f"purpose `{request.purpose}` is not in allowed_purposes") - if request.region.upper() not in capsule.resident_regions: - reasons.append(f"request region `{request.region}` is outside resident_regions") - if request.trains_model and not capsule.training_allowed: - reasons.append("model training is not allowed for this capsule") - if request.exports_data and not capsule.export_allowed: - reasons.append("data export is not allowed for this capsule") - if request.requested_retention_days and request.requested_retention_days > capsule.retention_days: - reasons.append("requested retention exceeds capsule retention_days") - if not set(request.data_classes).issubset(set(capsule.data_classes)): - reasons.append("request data_classes exceed capsule data_classes") - if _personal_detail_rank(request.personal_detail_level) > _personal_detail_rank( - capsule.personal_detail_level, - ): - reasons.append("requested personal_detail_level exceeds capsule consent") - if ( - request.consent_scope - and capsule.consent_scopes - and request.consent_scope not in capsule.consent_scopes - ): - reasons.append("request consent_scope is not covered by the capsule") - - model_is_lumynax_or_local = any( - marker in request.model_id.lower() - for marker in ("lumynax", "local", "llama.cpp", "gguf") - ) - if capsule.sensitivity in HIGH_IMPACT_SENSITIVITY and not model_is_lumynax_or_local: - reasons.append("high-impact data requires a local or LumynaX-governed model") - - if request.action in DESTRUCTIVE_ACTIONS and not request.human_approved: - reasons.append(f"action `{request.action}` requires human approval") - - if request.writes_files: - obligations.append("show_diff_before_write_or_commit") - if request.exports_data: - obligations.append("attach_export_manifest_and_recipient") - if request.trains_model: - obligations.append("attach_training_consent_record") - if capsule.sensitivity in HIGH_IMPACT_SENSITIVITY: - obligations.extend( - ( - "redact_unneeded_personal_data", - "route_only_to_resident_runtime", - "require_human_review_for_external_effects", - ), - ) - if "personal" in capsule.data_classes or capsule.sensitivity == "personal": - obligations.extend( - ( - "honour_data_subject_access_and_correction", - "minimise_personal_detail_in_prompt", - "keep_personal_trace_inside_capsule_retention", - ), - ) - - allowed = not reasons - if not allowed: - audit_tags.append("decision:deny") - else: - audit_tags.append("decision:allow") - return PolicyDecision( - allowed=allowed, - reasons=tuple(reasons), - obligations=tuple(dict.fromkeys(obligations)), - audit_tags=tuple(dict.fromkeys(audit_tags)), - ) +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +HIGH_IMPACT_SENSITIVITY = frozenset({"personal", "restricted", "health", "iwi", "taonga"}) +DESTRUCTIVE_ACTIONS = frozenset({"delete_file", "execute_shell", "network_export", "publish", "commit"}) +PERSONAL_DETAIL_LEVELS = { + "none": 0, + "anonymous": 1, + "pseudonymous": 2, + "identifiable": 3, + "sensitive_identifiable": 4, +} + + +def _tuple_of_text(value: object, *, default: tuple[str, ...] = ()) -> tuple[str, ...]: + if value in (None, ""): + return default + if isinstance(value, str): + return (value,) + if isinstance(value, (list, tuple, set)): + return tuple(str(item).strip() for item in value if str(item).strip()) + return (str(value).strip(),) + + +def _normal(value: str) -> str: + return value.strip().lower().replace(" ", "_").replace("-", "_") + + +def _personal_detail_rank(value: str) -> int: + return PERSONAL_DETAIL_LEVELS.get(_normal(value), 0) + + +@dataclass(frozen=True, slots=True) +class DataCapsule: + """Policy wrapper for a governed data subject, dataset, or workspace.""" + + capsule_id: str + subject_id: str + jurisdiction: str = "NZ" + sensitivity: str = "internal" + allowed_purposes: tuple[str, ...] = ("inference", "coding_assistance") + denied_purposes: tuple[str, ...] = () + resident_regions: tuple[str, ...] = ("NZ",) + data_classes: tuple[str, ...] = ("source_code",) + retention_days: int = 30 + export_allowed: bool = False + training_allowed: bool = False + personal_detail_level: str = "none" + consent_scopes: tuple[str, ...] = () + data_subject_rights: tuple[str, ...] = ( + "access", + "correction", + "deletion_request", + "processing_objection", + ) + revoked: bool = False + schema_context: str = "https://schema.org" + consent_record: str = "" + metadata: dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_payload(cls, payload: dict[str, Any]) -> DataCapsule: + return cls( + capsule_id=str(payload.get("capsule_id") or payload.get("id") or ""), + subject_id=str(payload.get("subject_id") or payload.get("subject") or ""), + jurisdiction=str(payload.get("jurisdiction") or "NZ"), + sensitivity=_normal(str(payload.get("sensitivity") or "internal")), + allowed_purposes=tuple(_normal(item) for item in _tuple_of_text(payload.get("allowed_purposes"), default=("inference",))), + denied_purposes=tuple(_normal(item) for item in _tuple_of_text(payload.get("denied_purposes"))), + resident_regions=tuple(str(item).upper() for item in _tuple_of_text(payload.get("resident_regions"), default=("NZ",))), + data_classes=tuple(_normal(item) for item in _tuple_of_text(payload.get("data_classes"), default=("source_code",))), + retention_days=int(payload.get("retention_days") or 30), + export_allowed=bool(payload.get("export_allowed", False)), + training_allowed=bool(payload.get("training_allowed", False)), + personal_detail_level=_normal( + str(payload.get("personal_detail_level") or "none"), + ), + consent_scopes=tuple( + _normal(item) for item in _tuple_of_text(payload.get("consent_scopes")) + ), + data_subject_rights=tuple( + _normal(item) + for item in _tuple_of_text( + payload.get("data_subject_rights"), + default=( + "access", + "correction", + "deletion_request", + "processing_objection", + ), + ) + ), + revoked=bool(payload.get("revoked", False)), + schema_context=str(payload.get("schema_context") or "https://schema.org"), + consent_record=str(payload.get("consent_record") or ""), + metadata=dict(payload.get("metadata") or {}), + ) + + def to_dict(self) -> dict[str, Any]: + return { + "capsule_id": self.capsule_id, + "subject_id": self.subject_id, + "jurisdiction": self.jurisdiction, + "sensitivity": self.sensitivity, + "allowed_purposes": list(self.allowed_purposes), + "denied_purposes": list(self.denied_purposes), + "resident_regions": list(self.resident_regions), + "data_classes": list(self.data_classes), + "retention_days": self.retention_days, + "export_allowed": self.export_allowed, + "training_allowed": self.training_allowed, + "personal_detail_level": self.personal_detail_level, + "consent_scopes": list(self.consent_scopes), + "data_subject_rights": list(self.data_subject_rights), + "revoked": self.revoked, + "schema_context": self.schema_context, + "consent_record": self.consent_record, + "metadata": dict(self.metadata), + } + + +@dataclass(frozen=True, slots=True) +class SovereignRequest: + actor: str + purpose: str + action: str + region: str = "NZ" + model_id: str = "local/lumynax" + data_classes: tuple[str, ...] = ("source_code",) + tool_name: str = "" + writes_files: bool = False + exports_data: bool = False + trains_model: bool = False + human_approved: bool = False + personal_detail_level: str = "none" + consent_scope: str = "" + requested_retention_days: int | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_payload(cls, payload: dict[str, Any]) -> SovereignRequest: + return cls( + actor=str(payload.get("actor") or "unknown"), + purpose=_normal(str(payload.get("purpose") or "inference")), + action=_normal(str(payload.get("action") or "read_context")), + region=str(payload.get("region") or "NZ").upper(), + model_id=str(payload.get("model_id") or "local/lumynax"), + data_classes=tuple(_normal(item) for item in _tuple_of_text(payload.get("data_classes"), default=("source_code",))), + tool_name=str(payload.get("tool_name") or ""), + writes_files=bool(payload.get("writes_files", False)), + exports_data=bool(payload.get("exports_data", False)), + trains_model=bool(payload.get("trains_model", False)), + human_approved=bool(payload.get("human_approved", False)), + personal_detail_level=_normal( + str(payload.get("personal_detail_level") or "none"), + ), + consent_scope=_normal(str(payload.get("consent_scope") or "")), + requested_retention_days=( + int(payload["requested_retention_days"]) + if payload.get("requested_retention_days") is not None + else None + ), + metadata=dict(payload.get("metadata") or {}), + ) + + def to_dict(self) -> dict[str, Any]: + return { + "actor": self.actor, + "purpose": self.purpose, + "action": self.action, + "region": self.region, + "model_id": self.model_id, + "data_classes": list(self.data_classes), + "tool_name": self.tool_name, + "writes_files": self.writes_files, + "exports_data": self.exports_data, + "trains_model": self.trains_model, + "human_approved": self.human_approved, + "personal_detail_level": self.personal_detail_level, + "consent_scope": self.consent_scope, + "requested_retention_days": self.requested_retention_days, + "metadata": dict(self.metadata), + } + + +@dataclass(frozen=True, slots=True) +class PolicyDecision: + allowed: bool + reasons: tuple[str, ...] + obligations: tuple[str, ...] + audit_tags: tuple[str, ...] + + def to_dict(self) -> dict[str, Any]: + return { + "allowed": self.allowed, + "reasons": list(self.reasons), + "obligations": list(self.obligations), + "audit_tags": list(self.audit_tags), + } + + +class SovereigntyPolicyEngine: + """Deterministic policy decision point for AbteeX SovereignCode.""" + + def evaluate(self, capsule: DataCapsule, request: SovereignRequest) -> PolicyDecision: + reasons: list[str] = [] + obligations: list[str] = [ + "write_immutable_audit_record", + f"retain_trace_no_more_than_{capsule.retention_days}_days", + "preserve_capsule_id_in_agent_trace", + ] + audit_tags: list[str] = [ + f"jurisdiction:{capsule.jurisdiction.upper()}", + f"sensitivity:{capsule.sensitivity}", + f"purpose:{request.purpose}", + ] + + if capsule.revoked: + reasons.append("capsule consent has been revoked") + if request.purpose in capsule.denied_purposes: + reasons.append(f"purpose `{request.purpose}` is explicitly denied") + if request.purpose not in capsule.allowed_purposes: + reasons.append(f"purpose `{request.purpose}` is not in allowed_purposes") + if request.region.upper() not in capsule.resident_regions: + reasons.append(f"request region `{request.region}` is outside resident_regions") + if request.trains_model and not capsule.training_allowed: + reasons.append("model training is not allowed for this capsule") + if request.exports_data and not capsule.export_allowed: + reasons.append("data export is not allowed for this capsule") + if request.requested_retention_days and request.requested_retention_days > capsule.retention_days: + reasons.append("requested retention exceeds capsule retention_days") + if not set(request.data_classes).issubset(set(capsule.data_classes)): + reasons.append("request data_classes exceed capsule data_classes") + if _personal_detail_rank(request.personal_detail_level) > _personal_detail_rank( + capsule.personal_detail_level, + ): + reasons.append("requested personal_detail_level exceeds capsule consent") + if ( + request.consent_scope + and capsule.consent_scopes + and request.consent_scope not in capsule.consent_scopes + ): + reasons.append("request consent_scope is not covered by the capsule") + + model_is_lumynax_or_local = any( + marker in request.model_id.lower() + for marker in ("lumynax", "local", "llama.cpp", "gguf") + ) + if capsule.sensitivity in HIGH_IMPACT_SENSITIVITY and not model_is_lumynax_or_local: + reasons.append("high-impact data requires a local or LumynaX-governed model") + + if request.action in DESTRUCTIVE_ACTIONS and not request.human_approved: + reasons.append(f"action `{request.action}` requires human approval") + + if request.writes_files: + obligations.append("show_diff_before_write_or_commit") + if request.exports_data: + obligations.append("attach_export_manifest_and_recipient") + if request.trains_model: + obligations.append("attach_training_consent_record") + if capsule.sensitivity in HIGH_IMPACT_SENSITIVITY: + obligations.extend( + ( + "redact_unneeded_personal_data", + "route_only_to_resident_runtime", + "require_human_review_for_external_effects", + ), + ) + if "personal" in capsule.data_classes or capsule.sensitivity == "personal": + obligations.extend( + ( + "honour_data_subject_access_and_correction", + "minimise_personal_detail_in_prompt", + "keep_personal_trace_inside_capsule_retention", + ), + ) + + allowed = not reasons + if not allowed: + audit_tags.append("decision:deny") + else: + audit_tags.append("decision:allow") + return PolicyDecision( + allowed=allowed, + reasons=tuple(reasons), + obligations=tuple(dict.fromkeys(obligations)), + audit_tags=tuple(dict.fromkeys(audit_tags)), + ) diff --git a/sovereigncode/schemas/data_capsule.schema.json b/sovereigncode/schemas/data_capsule.schema.json index 3d24daaa7314820433bb24b994023edc2d46ce80..382aeb8c81aa0bc19e22707a58392ca66e98a297 100644 --- a/sovereigncode/schemas/data_capsule.schema.json +++ b/sovereigncode/schemas/data_capsule.schema.json @@ -1,88 +1,88 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://abteex.com/schemas/sovereigncode/data-capsule.schema.json", - "title": "AbteeX SovereignCode Data Capsule", - "type": "object", - "required": ["capsule_id", "subject_id", "jurisdiction", "sensitivity"], - "additionalProperties": true, - "properties": { - "capsule_id": { - "type": "string", - "minLength": 1 - }, - "subject_id": { - "type": "string", - "minLength": 1 - }, - "jurisdiction": { - "type": "string", - "default": "NZ" - }, - "sensitivity": { - "type": "string", - "enum": ["public", "internal", "restricted", "personal", "health", "iwi", "taonga"] - }, - "allowed_purposes": { - "type": "array", - "items": { "type": "string" }, - "default": ["inference", "coding_assistance"] - }, - "denied_purposes": { - "type": "array", - "items": { "type": "string" }, - "default": [] - }, - "resident_regions": { - "type": "array", - "items": { "type": "string" }, - "default": ["NZ"] - }, - "data_classes": { - "type": "array", - "items": { "type": "string" }, - "default": ["source_code"] - }, - "retention_days": { - "type": "integer", - "minimum": 0, - "default": 30 - }, - "export_allowed": { - "type": "boolean", - "default": false - }, - "training_allowed": { - "type": "boolean", - "default": false - }, - "personal_detail_level": { - "type": "string", - "enum": ["none", "anonymous", "pseudonymous", "identifiable", "sensitive_identifiable"], - "default": "none" - }, - "consent_scopes": { - "type": "array", - "items": { "type": "string" }, - "default": [] - }, - "data_subject_rights": { - "type": "array", - "items": { "type": "string" }, - "default": ["access", "correction", "deletion_request", "processing_objection"] - }, - "revoked": { - "type": "boolean", - "default": false - }, - "schema_context": { - "type": "string", - "default": "https://schema.org" - }, - "consent_record": { - "type": "string" - }, - "metadata": { - "type": "object" - } - } -} +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://abteex.com/schemas/sovereigncode/data-capsule.schema.json", + "title": "AbteeX SovereignCode Data Capsule", + "type": "object", + "required": ["capsule_id", "subject_id", "jurisdiction", "sensitivity"], + "additionalProperties": true, + "properties": { + "capsule_id": { + "type": "string", + "minLength": 1 + }, + "subject_id": { + "type": "string", + "minLength": 1 + }, + "jurisdiction": { + "type": "string", + "default": "NZ" + }, + "sensitivity": { + "type": "string", + "enum": ["public", "internal", "restricted", "personal", "health", "iwi", "taonga"] + }, + "allowed_purposes": { + "type": "array", + "items": { "type": "string" }, + "default": ["inference", "coding_assistance"] + }, + "denied_purposes": { + "type": "array", + "items": { "type": "string" }, + "default": [] + }, + "resident_regions": { + "type": "array", + "items": { "type": "string" }, + "default": ["NZ"] + }, + "data_classes": { + "type": "array", + "items": { "type": "string" }, + "default": ["source_code"] + }, + "retention_days": { + "type": "integer", + "minimum": 0, + "default": 30 + }, + "export_allowed": { + "type": "boolean", + "default": false + }, + "training_allowed": { + "type": "boolean", + "default": false + }, + "personal_detail_level": { + "type": "string", + "enum": ["none", "anonymous", "pseudonymous", "identifiable", "sensitive_identifiable"], + "default": "none" + }, + "consent_scopes": { + "type": "array", + "items": { "type": "string" }, + "default": [] + }, + "data_subject_rights": { + "type": "array", + "items": { "type": "string" }, + "default": ["access", "correction", "deletion_request", "processing_objection"] + }, + "revoked": { + "type": "boolean", + "default": false + }, + "schema_context": { + "type": "string", + "default": "https://schema.org" + }, + "consent_record": { + "type": "string" + }, + "metadata": { + "type": "object" + } + } +} diff --git a/sovereigncode/schemas/openai_chat_route_request.schema.json b/sovereigncode/schemas/openai_chat_route_request.schema.json index e0f4e342a4e6be01a036e187046655e2583ad4fa..5c1de107347e6bca858c96c795243bbde2a933fe 100644 --- a/sovereigncode/schemas/openai_chat_route_request.schema.json +++ b/sovereigncode/schemas/openai_chat_route_request.schema.json @@ -1,95 +1,95 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://abteex.com/schemas/marama-route/openai-chat-route-request.schema.json", - "title": "MaramaRoute OpenAI-Compatible Chat Route Request", - "type": "object", - "required": ["messages"], - "additionalProperties": true, - "properties": { - "model": { - "type": "string", - "default": "lumynax/auto" - }, - "messages": { - "type": "array", - "items": { - "type": "object", - "required": ["role", "content"], - "properties": { - "role": { - "type": "string" - }, - "content": {} - }, - "additionalProperties": true - } - }, - "tools": { - "type": "array" - }, - "response_format": { - "type": "object" - }, - "route": { - "$ref": "#/$defs/routeOptions" - }, - "routing": { - "$ref": "#/$defs/routeOptions" - }, - "metadata": { - "type": "object", - "properties": { - "marama_route": { - "$ref": "#/$defs/routeOptions" - } - }, - "additionalProperties": true - } - }, - "$defs": { - "routeOptions": { - "type": "object", - "additionalProperties": true, - "properties": { - "jurisdiction": { - "type": "string", - "default": "NZ" - }, - "data_sensitivity": { - "type": "string", - "default": "internal" - }, - "task_type": { - "type": "string", - "enum": ["general", "code", "reasoning", "multimodal", "embedding"] - }, - "min_context_tokens": { - "type": "integer", - "minimum": 1, - "default": 4096 - }, - "requires_local": { - "type": "boolean", - "default": true - }, - "requires_tools": { - "type": "boolean", - "default": false - }, - "requires_json": { - "type": "boolean", - "default": false - }, - "license_allowlist": { - "type": "array", - "items": { "type": "string" } - }, - "max_fallbacks": { - "type": "integer", - "minimum": 0, - "default": 3 - } - } - } - } -} +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://abteex.com/schemas/marama-route/openai-chat-route-request.schema.json", + "title": "MaramaRoute OpenAI-Compatible Chat Route Request", + "type": "object", + "required": ["messages"], + "additionalProperties": true, + "properties": { + "model": { + "type": "string", + "default": "lumynax/auto" + }, + "messages": { + "type": "array", + "items": { + "type": "object", + "required": ["role", "content"], + "properties": { + "role": { + "type": "string" + }, + "content": {} + }, + "additionalProperties": true + } + }, + "tools": { + "type": "array" + }, + "response_format": { + "type": "object" + }, + "route": { + "$ref": "#/$defs/routeOptions" + }, + "routing": { + "$ref": "#/$defs/routeOptions" + }, + "metadata": { + "type": "object", + "properties": { + "marama_route": { + "$ref": "#/$defs/routeOptions" + } + }, + "additionalProperties": true + } + }, + "$defs": { + "routeOptions": { + "type": "object", + "additionalProperties": true, + "properties": { + "jurisdiction": { + "type": "string", + "default": "NZ" + }, + "data_sensitivity": { + "type": "string", + "default": "internal" + }, + "task_type": { + "type": "string", + "enum": ["general", "code", "reasoning", "multimodal", "embedding"] + }, + "min_context_tokens": { + "type": "integer", + "minimum": 1, + "default": 4096 + }, + "requires_local": { + "type": "boolean", + "default": true + }, + "requires_tools": { + "type": "boolean", + "default": false + }, + "requires_json": { + "type": "boolean", + "default": false + }, + "license_allowlist": { + "type": "array", + "items": { "type": "string" } + }, + "max_fallbacks": { + "type": "integer", + "minimum": 0, + "default": 3 + } + } + } + } +} diff --git a/sovereigncode/server.py b/sovereigncode/server.py new file mode 100644 index 0000000000000000000000000000000000000000..933409502c15466efd3764697f0e021a05a1d918 --- /dev/null +++ b/sovereigncode/server.py @@ -0,0 +1,352 @@ +from __future__ import annotations + +import json +import os +import tempfile +from pathlib import Path +from typing import Any + +try: # repo package + from tinyluminax.products._ui_server import serve_dashboard +except ModuleNotFoundError: # standalone HF package + from ._ui_server import serve_dashboard + +try: # repo package + from tinyluminax.products.marama_route import RoutingRequest, load_model_registry +except ModuleNotFoundError: # standalone HF package + from marama_route import RoutingRequest, load_model_registry + +from .audit import build_audit_record +from .ledger import AuditLedger, default_ledger_path +from .planner import plan_coding_turn +from .platform import ( + build_capsule_summary, + build_policy_matrix, + build_turn_brief, + check_tool_request, +) +from .policy import DataCapsule, SovereignRequest, SovereigntyPolicyEngine +from .ui import ( + PRODUCT_NAME, + build_dashboard_html, + build_dashboard_state, + default_capsule_path, + default_registry_path, + default_request_path, + default_route_request_path, + handle_api_request, +) + + +def handle_service_request( + method: str, + path: str, + payload: dict[str, Any] | None, + *, + capsule_path: Path, + request_path: Path, + route_request_path: Path, + registry_path: Path, + ledger_path: Path, + state: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + ledger = AuditLedger(ledger_path) + service_state = state or build_dashboard_state(capsule_path, request_path, route_request_path, registry_path) + + if path.startswith("/api/"): + return handle_api_request(method, path, payload, registry_path, service_state) + if method == "GET" and path in {"/health", "/v1/health"}: + return 200, _health_payload(ledger, service_state) + if method == "GET" and path == "/v1/audit": + return 200, {"ok": True, "ledger_path": str(ledger.path), "records": ledger.tail()} + if method == "GET" and path == "/v1/capsule-summary": + return 200, {"ok": True, "summary": build_capsule_summary(service_state["capsule"])} + if method == "POST" and path == "/v1/evaluate": + result = evaluate_payload(payload or {}, service_state, ledger) + return (200 if result["decision"]["allowed"] else 422), result + if method == "POST" and path == "/v1/plan-turn": + result = plan_turn_payload(payload or {}, service_state, ledger) + return (200 if result["allowed"] else 422), result + if method == "POST" and path == "/v1/tool-check": + result = tool_check_payload(payload or {}, service_state, ledger) + return (200 if result["ok"] else 422), result + if method == "POST" and path == "/v1/policy-matrix": + return 200, policy_matrix_payload(payload or {}, service_state, ledger) + if method == "POST" and path == "/v1/capsule-summary": + capsule_payload = _mapping(payload, "capsule") or service_state["capsule"] + return 200, {"ok": True, "summary": build_capsule_summary(capsule_payload)} + return 404, {"ok": False, "error": "not_found"} + + +def evaluate_payload( + payload: dict[str, Any], + state: dict[str, Any], + ledger: AuditLedger, +) -> dict[str, Any]: + capsule = DataCapsule.from_payload(_mapping(payload, "capsule") or state["capsule"]) + request = SovereignRequest.from_payload(_mapping(payload, "request") or state["request"]) + decision = SovereigntyPolicyEngine().evaluate(capsule, request) + audit = build_audit_record(capsule, request, decision).to_dict() + ledger_record = ledger.append( + "policy_evaluate", + { + "capsule_id": capsule.capsule_id, + "request": request.to_dict(), + "decision": decision.to_dict(), + "audit_record": audit, + }, + ) + return { + "ok": decision.allowed, + "decision": decision.to_dict(), + "audit_record": audit, + "ledger_record": ledger_record, + } + + +def plan_turn_payload( + payload: dict[str, Any], + state: dict[str, Any], + ledger: AuditLedger, +) -> dict[str, Any]: + capsule = DataCapsule.from_payload(_mapping(payload, "capsule") or state["capsule"]) + request = SovereignRequest.from_payload(_mapping(payload, "request") or state["request"]) + route_request = RoutingRequest.from_payload(_mapping(payload, "route_request") or state["route_request"]) + models = load_model_registry(Path(str(payload.get("registry_path") or state["registry_path"]))) + plan = plan_coding_turn(capsule, request, route_request, models) + result = plan.to_dict() + result["turn_brief"] = build_turn_brief(result) + result["ledger_record"] = ledger.append( + "plan_turn", + { + "capsule_id": capsule.capsule_id, + "allowed": plan.allowed, + "policy_decision": result["policy_decision"], + "route_decision": result["route_decision"], + "audit_record": result["audit_record"], + "turn_brief": result["turn_brief"], + }, + ) + result["ok"] = plan.allowed + return result + + +def tool_check_payload( + payload: dict[str, Any], + state: dict[str, Any], + ledger: AuditLedger, +) -> dict[str, Any]: + tool_payload = _mapping(payload, "tool") or { + "tool_name": payload.get("tool_name", "workspace_reader"), + "action": payload.get("action", "read_context"), + "writes_files": bool(payload.get("writes_files", False)), + "exports_data": bool(payload.get("exports_data", False)), + "trains_model": bool(payload.get("trains_model", False)), + "human_approved": bool(payload.get("human_approved", False)), + } + result = check_tool_request( + _mapping(payload, "capsule") or state["capsule"], + _mapping(payload, "request") or state["request"], + tool_payload, + ) + result["ledger_record"] = ledger.append( + "tool_check", + { + "tool": tool_payload, + "decision": result["decision"], + "audit_record": result["audit_record"], + "operator_gate": result["operator_gate"], + }, + ) + return result + + +def policy_matrix_payload( + payload: dict[str, Any], + state: dict[str, Any], + ledger: AuditLedger, +) -> dict[str, Any]: + scenarios = payload.get("scenarios") if isinstance(payload.get("scenarios"), list) else None + result = build_policy_matrix( + _mapping(payload, "capsule") or state["capsule"], + _mapping(payload, "request") or state["request"], + scenarios, + ) + result["ledger_record"] = ledger.append( + "policy_matrix", + { + "capsule_id": result["capsule_id"], + "allowed_count": result["allowed_count"], + "blocked_count": result["blocked_count"], + }, + ) + return result + + +def smoke_service( + *, + capsule_path: Path | None = None, + request_path: Path | None = None, + route_request_path: Path | None = None, + registry_path: Path | None = None, + ledger_path: Path | None = None, +) -> dict[str, Any]: + resolved_capsule = capsule_path or default_capsule_path() + resolved_request = request_path or default_request_path() + resolved_route_request = route_request_path or default_route_request_path() + resolved_registry = registry_path or default_registry_path() + resolved_ledger = ledger_path or _temporary_ledger_path() + state = build_dashboard_state(resolved_capsule, resolved_request, resolved_route_request, resolved_registry) + + health_status, health = handle_service_request( + "GET", + "/health", + None, + capsule_path=resolved_capsule, + request_path=resolved_request, + route_request_path=resolved_route_request, + registry_path=resolved_registry, + ledger_path=resolved_ledger, + state=state, + ) + evaluate_status, evaluate = handle_service_request( + "POST", + "/v1/evaluate", + {}, + capsule_path=resolved_capsule, + request_path=resolved_request, + route_request_path=resolved_route_request, + registry_path=resolved_registry, + ledger_path=resolved_ledger, + state=state, + ) + plan_status, plan = handle_service_request( + "POST", + "/v1/plan-turn", + {}, + capsule_path=resolved_capsule, + request_path=resolved_request, + route_request_path=resolved_route_request, + registry_path=resolved_registry, + ledger_path=resolved_ledger, + state=state, + ) + tool_status, tool = handle_service_request( + "POST", + "/v1/tool-check", + {"tool_name": "workspace_reader", "action": "read_context"}, + capsule_path=resolved_capsule, + request_path=resolved_request, + route_request_path=resolved_route_request, + registry_path=resolved_registry, + ledger_path=resolved_ledger, + state=state, + ) + audit_status, audit = handle_service_request( + "GET", + "/v1/audit", + None, + capsule_path=resolved_capsule, + request_path=resolved_request, + route_request_path=resolved_route_request, + registry_path=resolved_registry, + ledger_path=resolved_ledger, + state=state, + ) + + if (health_status, evaluate_status, plan_status, tool_status, audit_status) != (200, 200, 200, 200, 200): + raise RuntimeError("SovereignCode service smoke failed") + if not evaluate["decision"]["allowed"] or not plan["allowed"] or not tool["ok"]: + raise RuntimeError("SovereignCode service smoke did not allow the governed local request") + return { + "ok": True, + "product": PRODUCT_NAME, + "capsule_id": state["capsule"]["capsule_id"], + "selected_model": plan["turn_brief"]["selected_model"], + "ledger_path": str(resolved_ledger), + "ledger_records": len(audit["records"]), + "tool_next_gate": tool["operator_gate"]["next_gate"], + } + + +def serve_service( + *, + capsule_path: Path | None = None, + request_path: Path | None = None, + route_request_path: Path | None = None, + registry_path: Path | None = None, + ledger_path: Path | None = None, + host: str = "127.0.0.1", + port: int = 8788, + open_browser: bool = False, + smoke: bool = False, +) -> int: + resolved_capsule = capsule_path or default_capsule_path() + resolved_request = request_path or default_request_path() + resolved_route_request = route_request_path or default_route_request_path() + resolved_registry = registry_path or default_registry_path() + resolved_ledger = ledger_path or default_ledger_path() + if smoke: + print( + json.dumps( + smoke_service( + capsule_path=resolved_capsule, + request_path=resolved_request, + route_request_path=resolved_route_request, + registry_path=resolved_registry, + ledger_path=ledger_path, + ), + indent=2, + sort_keys=True, + ), + ) + return 0 + + state = build_dashboard_state(resolved_capsule, resolved_request, resolved_route_request, resolved_registry) + html = build_dashboard_html(state) + return serve_dashboard( + product_name=f"{PRODUCT_NAME} Service", + html=html, + api_handler=lambda method, path, request_payload: handle_service_request( + method, + path, + request_payload, + capsule_path=resolved_capsule, + request_path=resolved_request, + route_request_path=resolved_route_request, + registry_path=resolved_registry, + ledger_path=resolved_ledger, + state=state, + ), + host=host, + port=port, + open_browser=open_browser, + api_path_prefixes=("/api/", "/v1/"), + api_exact_paths=("/health",), + ) + + +def _health_payload(ledger: AuditLedger, state: dict[str, Any]) -> dict[str, Any]: + return { + "ok": True, + "product": PRODUCT_NAME, + "capsule_id": state["capsule"]["capsule_id"], + "ledger_path": str(ledger.path), + "ledger_records": len(ledger.tail()), + "service": "policy_api_audit_ledger", + } + + +def _mapping(payload: dict[str, Any] | None, key: str) -> dict[str, Any]: + if not isinstance(payload, dict): + return {} + value = payload.get(key) + return dict(value) if isinstance(value, dict) else {} + + +def _temporary_ledger_path() -> Path: + handle, raw_path = tempfile.mkstemp(prefix="sovereigncode-smoke-", suffix=".audit.jsonl") + os.close(handle) + path = Path(raw_path) + path.unlink(missing_ok=True) + return path