multiagentos 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agents/__init__.py +1 -0
- agents/catalog.py +52 -0
- agents/registry.py +11 -0
- core/__init__.py +38 -0
- core/artifacts.py +47 -0
- core/chat_agent_bridge.py +280 -0
- core/chat_agent_registry.py +61 -0
- core/chat_agent_router.py +85 -0
- core/chat_session.py +62 -0
- core/contracts/__init__.py +22 -0
- core/contracts/agent.py +21 -0
- core/contracts/ai.py +25 -0
- core/contracts/capability.py +44 -0
- core/contracts/chat_agent.py +34 -0
- core/contracts/checkpoint.py +78 -0
- core/contracts/discovery.py +30 -0
- core/contracts/execution.py +28 -0
- core/contracts/github.py +74 -0
- core/contracts/handoff.py +56 -0
- core/contracts/health.py +206 -0
- core/contracts/human_review.py +20 -0
- core/contracts/ide.py +113 -0
- core/contracts/mcp.py +76 -0
- core/contracts/model_runtime.py +25 -0
- core/contracts/planning.py +25 -0
- core/contracts/profile.py +66 -0
- core/contracts/quota.py +62 -0
- core/contracts/repository.py +34 -0
- core/contracts/resume.py +55 -0
- core/contracts/runtime.py +19 -0
- core/contracts/vyrelon_runtime.py +118 -0
- core/contracts/work_unit.py +48 -0
- core/delegation.py +33 -0
- core/handoff.py +68 -0
- core/lifecycle.py +85 -0
- core/multi_agent_workflow.py +574 -0
- core/orchestrator.py +51 -0
- core/planning.py +22 -0
- core/registry.py +44 -0
- core/routing.py +195 -0
- core/state.py +155 -0
- installer/__init__.py +1 -0
- installer/init.py +71 -0
- integrations/__init__.py +0 -0
- integrations/github/__init__.py +3 -0
- integrations/github/gateway.py +164 -0
- integrations/openai/__init__.py +1 -0
- integrations/openai/chat_agent.py +147 -0
- multiagentos/__init__.py +1 -0
- multiagentos/cli.py +759 -0
- multiagentos-0.4.0.dist-info/METADATA +7 -0
- multiagentos-0.4.0.dist-info/RECORD +122 -0
- multiagentos-0.4.0.dist-info/WHEEL +5 -0
- multiagentos-0.4.0.dist-info/entry_points.txt +2 -0
- multiagentos-0.4.0.dist-info/top_level.txt +7 -0
- profiles/__init__.py +1 -0
- profiles/common.py +11 -0
- profiles/detector.py +67 -0
- profiles/registry.py +46 -0
- profiles/resolver.py +63 -0
- runtime/__init__.py +5 -0
- runtime/agent/__init__.py +1 -0
- runtime/agent/ide.py +74 -0
- runtime/agent/model.py +197 -0
- runtime/agent/model_backed.py +49 -0
- runtime/agent/process.py +21 -0
- runtime/builtin_tools.py +57 -0
- runtime/capability.py +143 -0
- runtime/chat_adapter_registry.py +43 -0
- runtime/chat_config.py +57 -0
- runtime/chat_execution.py +52 -0
- runtime/config/__init__.py +1 -0
- runtime/config/loader.py +67 -0
- runtime/discovery.py +334 -0
- runtime/execution_config.py +71 -0
- runtime/execution_registry.py +50 -0
- runtime/git.py +84 -0
- runtime/github.py +56 -0
- runtime/github_probe.py +23 -0
- runtime/health.py +14 -0
- runtime/ide/__init__.py +6 -0
- runtime/ide/bridge.py +198 -0
- runtime/ide/registry.py +24 -0
- runtime/ide/runtime.py +35 -0
- runtime/local/__init__.py +18 -0
- runtime/local/filesystem.py +58 -0
- runtime/local/patch.py +79 -0
- runtime/local/path_security.py +31 -0
- runtime/local/permissions.py +20 -0
- runtime/local/process.py +56 -0
- runtime/local/shell.py +97 -0
- runtime/mcp/__init__.py +7 -0
- runtime/mcp/client.py +120 -0
- runtime/mcp/config.py +36 -0
- runtime/mcp/policy.py +57 -0
- runtime/mcp/proxy.py +30 -0
- runtime/mcp/server.py +137 -0
- runtime/mcp/session.py +19 -0
- runtime/model/__init__.py +5 -0
- runtime/model/ai_runtime.py +165 -0
- runtime/model/cli.py +49 -0
- runtime/model/config.py +98 -0
- runtime/model/credentials.py +42 -0
- runtime/model/factory.py +82 -0
- runtime/model/http.py +78 -0
- runtime/model/invoker.py +16 -0
- runtime/model/materializer.py +101 -0
- runtime/model/native.py +244 -0
- runtime/model/providers.py +55 -0
- runtime/model/registry.py +26 -0
- runtime/model/runtime_loader.py +51 -0
- runtime/model_control.py +136 -0
- runtime/multi_agent.py +142 -0
- runtime/policy.py +28 -0
- runtime/process.py +41 -0
- runtime/quota.py +259 -0
- runtime/repository.py +103 -0
- runtime/repository_tools.py +62 -0
- runtime/status.py +87 -0
- runtime/tool_calling.py +134 -0
- runtime/validation.py +121 -0
- runtime/vyrelon.py +1557 -0
runtime/model/config.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Load provider/model configuration from project JSON without vendor-specific branches."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from core.contracts.ai import AIProvider, ModelSpec
|
|
10
|
+
from runtime.model.providers import AIProviderRegistry
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
DEFAULT_CONFIG_PATH = ".multiagentos/providers.json"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ProviderConfigLoader:
|
|
17
|
+
"""Parse a declarative provider configuration into the VYRELON registry."""
|
|
18
|
+
|
|
19
|
+
def load(
|
|
20
|
+
self,
|
|
21
|
+
path: Path,
|
|
22
|
+
registry: AIProviderRegistry | None = None,
|
|
23
|
+
) -> AIProviderRegistry:
|
|
24
|
+
target = registry or AIProviderRegistry()
|
|
25
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
26
|
+
if not isinstance(payload, dict):
|
|
27
|
+
raise ValueError("Provider configuration root must be an object")
|
|
28
|
+
|
|
29
|
+
providers = payload.get("providers", [])
|
|
30
|
+
if not isinstance(providers, list):
|
|
31
|
+
raise ValueError("'providers' must be a list")
|
|
32
|
+
|
|
33
|
+
for provider_data in providers:
|
|
34
|
+
provider = self._provider(provider_data)
|
|
35
|
+
if provider.id in target.list_provider_ids():
|
|
36
|
+
continue
|
|
37
|
+
target.register(provider)
|
|
38
|
+
return target
|
|
39
|
+
|
|
40
|
+
def _provider(self, value: Any) -> AIProvider:
|
|
41
|
+
if not isinstance(value, dict):
|
|
42
|
+
raise ValueError("Provider entry must be an object")
|
|
43
|
+
|
|
44
|
+
provider_id = self._required_string(value, "id")
|
|
45
|
+
kind = self._required_string(value, "kind")
|
|
46
|
+
models_data = value.get("models", [])
|
|
47
|
+
if not isinstance(models_data, list):
|
|
48
|
+
raise ValueError(f"Provider {provider_id}: 'models' must be a list")
|
|
49
|
+
|
|
50
|
+
models = tuple(
|
|
51
|
+
self._model(provider_id, item)
|
|
52
|
+
for item in models_data
|
|
53
|
+
)
|
|
54
|
+
metadata = value.get("metadata", {})
|
|
55
|
+
if not isinstance(metadata, dict):
|
|
56
|
+
raise ValueError(f"Provider {provider_id}: 'metadata' must be an object")
|
|
57
|
+
|
|
58
|
+
return AIProvider(
|
|
59
|
+
id=provider_id,
|
|
60
|
+
kind=kind,
|
|
61
|
+
models=models,
|
|
62
|
+
metadata=dict(metadata),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def _model(self, provider_id: str, value: Any) -> ModelSpec:
|
|
66
|
+
if not isinstance(value, dict):
|
|
67
|
+
raise ValueError(f"Provider {provider_id}: model entry must be an object")
|
|
68
|
+
|
|
69
|
+
model_id = self._required_string(value, "id")
|
|
70
|
+
capabilities = value.get("capabilities", [])
|
|
71
|
+
if not isinstance(capabilities, list) or not all(
|
|
72
|
+
isinstance(item, str) for item in capabilities
|
|
73
|
+
):
|
|
74
|
+
raise ValueError(
|
|
75
|
+
f"Provider {provider_id}, model {model_id}: "
|
|
76
|
+
"'capabilities' must be a list of strings"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
metadata = value.get("metadata", {})
|
|
80
|
+
if not isinstance(metadata, dict):
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f"Provider {provider_id}, model {model_id}: "
|
|
83
|
+
"'metadata' must be an object"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return ModelSpec(
|
|
87
|
+
id=model_id,
|
|
88
|
+
provider_id=provider_id,
|
|
89
|
+
capabilities=frozenset(capabilities),
|
|
90
|
+
metadata=dict(metadata),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def _required_string(value: dict[str, Any], key: str) -> str:
|
|
95
|
+
item = value.get(key)
|
|
96
|
+
if not isinstance(item, str) or not item:
|
|
97
|
+
raise ValueError(f"Configuration field '{key}' must be a non-empty string")
|
|
98
|
+
return item
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Environment-based credential validation for model providers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Mapping
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class CredentialCheck:
|
|
12
|
+
"""Non-secret result of checking one environment-backed credential."""
|
|
13
|
+
|
|
14
|
+
environment_variable: str
|
|
15
|
+
present: bool
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EnvironmentCredentialResolver:
|
|
19
|
+
"""Resolve credentials from environment variables without persisting secrets."""
|
|
20
|
+
|
|
21
|
+
def resolve(self, environment_variable: str) -> str:
|
|
22
|
+
value = os.environ.get(environment_variable)
|
|
23
|
+
if not value:
|
|
24
|
+
raise RuntimeError(
|
|
25
|
+
f"Required model credential environment variable is missing: "
|
|
26
|
+
f"{environment_variable}"
|
|
27
|
+
)
|
|
28
|
+
return value
|
|
29
|
+
|
|
30
|
+
def check(self, environment_variables: list[str]) -> tuple[CredentialCheck, ...]:
|
|
31
|
+
return tuple(
|
|
32
|
+
CredentialCheck(
|
|
33
|
+
environment_variable=name,
|
|
34
|
+
present=bool(os.environ.get(name)),
|
|
35
|
+
)
|
|
36
|
+
for name in environment_variables
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def check_mapping(
|
|
40
|
+
self, header_env: Mapping[str, str]
|
|
41
|
+
) -> tuple[CredentialCheck, ...]:
|
|
42
|
+
return self.check(list(header_env.values()))
|
runtime/model/factory.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Materialize generic model adapters from declarative provider configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from runtime.model.cli import CLIModelAdapter
|
|
8
|
+
from runtime.model.http import HTTPModelAdapter
|
|
9
|
+
from runtime.policy import ExecutionPolicy
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConfiguredAdapterFactory:
|
|
13
|
+
"""Build only the provider-neutral adapters supported by MultiAgentOS."""
|
|
14
|
+
|
|
15
|
+
def build(
|
|
16
|
+
self,
|
|
17
|
+
adapter_id: str,
|
|
18
|
+
metadata: dict[str, object],
|
|
19
|
+
policy: ExecutionPolicy | None = None,
|
|
20
|
+
):
|
|
21
|
+
kind = str(metadata.get("adapter_kind", adapter_id))
|
|
22
|
+
effective_policy = policy or ExecutionPolicy()
|
|
23
|
+
|
|
24
|
+
if kind == "cli":
|
|
25
|
+
command = metadata.get("command")
|
|
26
|
+
if not isinstance(command, list) or not all(
|
|
27
|
+
isinstance(part, str) and part for part in command
|
|
28
|
+
):
|
|
29
|
+
raise ValueError(
|
|
30
|
+
f"CLI adapter {adapter_id} requires metadata.command as a non-empty list"
|
|
31
|
+
)
|
|
32
|
+
timeout = metadata.get("timeout", 300)
|
|
33
|
+
if not isinstance(timeout, (int, float)) or timeout <= 0:
|
|
34
|
+
raise ValueError(f"CLI adapter {adapter_id} timeout must be positive")
|
|
35
|
+
return CLIModelAdapter(
|
|
36
|
+
command=tuple(command),
|
|
37
|
+
policy=effective_policy,
|
|
38
|
+
timeout=float(timeout),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if kind == "http":
|
|
42
|
+
endpoint = metadata.get("endpoint")
|
|
43
|
+
if not isinstance(endpoint, str) or not endpoint:
|
|
44
|
+
raise ValueError(
|
|
45
|
+
f"HTTP adapter {adapter_id} requires metadata.endpoint"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
headers = self._string_mapping(metadata.get("headers", {}), "headers")
|
|
49
|
+
header_env = self._string_mapping(
|
|
50
|
+
metadata.get("header_env", {}), "header_env"
|
|
51
|
+
)
|
|
52
|
+
request_template = metadata.get("request_template", {})
|
|
53
|
+
if not isinstance(request_template, dict):
|
|
54
|
+
raise ValueError(
|
|
55
|
+
f"HTTP adapter {adapter_id} request_template must be an object"
|
|
56
|
+
)
|
|
57
|
+
response_path = metadata.get("response_path", ["text"])
|
|
58
|
+
if not isinstance(response_path, list) or not all(
|
|
59
|
+
isinstance(part, str) for part in response_path
|
|
60
|
+
):
|
|
61
|
+
raise ValueError(
|
|
62
|
+
f"HTTP adapter {adapter_id} response_path must be a list of strings"
|
|
63
|
+
)
|
|
64
|
+
return HTTPModelAdapter(
|
|
65
|
+
endpoint=endpoint,
|
|
66
|
+
headers=headers,
|
|
67
|
+
header_env=header_env,
|
|
68
|
+
request_template=request_template,
|
|
69
|
+
response_path=tuple(response_path),
|
|
70
|
+
policy=effective_policy,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
raise ValueError(f"Unsupported model adapter kind: {kind}")
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def _string_mapping(value: Any, field_name: str) -> dict[str, str]:
|
|
77
|
+
if not isinstance(value, dict) or not all(
|
|
78
|
+
isinstance(key, str) and isinstance(item, str)
|
|
79
|
+
for key, item in value.items()
|
|
80
|
+
):
|
|
81
|
+
raise ValueError(f"{field_name} must be an object of strings")
|
|
82
|
+
return dict(value)
|
runtime/model/http.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Generic HTTP JSON model adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import urllib.request
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from core.contracts.ai import ModelSpec
|
|
12
|
+
from core.contracts.model_runtime import ModelRequest, ModelResponse
|
|
13
|
+
from runtime.policy import ExecutionPolicy
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class HTTPModelAdapter:
|
|
18
|
+
endpoint: str
|
|
19
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
20
|
+
header_env: dict[str, str] = field(default_factory=dict)
|
|
21
|
+
request_template: dict[str, Any] = field(
|
|
22
|
+
default_factory=lambda: {
|
|
23
|
+
"model": "{model_id}",
|
|
24
|
+
"prompt": "{prompt}",
|
|
25
|
+
"system": "{system}",
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
response_path: tuple[str, ...] = ("text",)
|
|
29
|
+
policy: ExecutionPolicy = ExecutionPolicy(allow_network=True)
|
|
30
|
+
|
|
31
|
+
def generate(self, model: ModelSpec, request: ModelRequest) -> ModelResponse:
|
|
32
|
+
if not self.policy.permits("network"):
|
|
33
|
+
raise PermissionError("Network model execution is disabled by policy")
|
|
34
|
+
|
|
35
|
+
payload = self._render(self.request_template, model, request)
|
|
36
|
+
headers = {"Content-Type": "application/json", **self.headers}
|
|
37
|
+
for header, env_name in self.header_env.items():
|
|
38
|
+
value = os.environ.get(env_name)
|
|
39
|
+
if not value:
|
|
40
|
+
raise RuntimeError(f"Required model credential environment variable is missing: {env_name}")
|
|
41
|
+
headers[header] = value
|
|
42
|
+
|
|
43
|
+
body = json.dumps(payload).encode("utf-8")
|
|
44
|
+
http_request = urllib.request.Request(
|
|
45
|
+
self.endpoint,
|
|
46
|
+
data=body,
|
|
47
|
+
headers=headers,
|
|
48
|
+
method="POST",
|
|
49
|
+
)
|
|
50
|
+
with urllib.request.urlopen(http_request, timeout=300) as response:
|
|
51
|
+
data = json.loads(response.read().decode("utf-8"))
|
|
52
|
+
|
|
53
|
+
value: Any = data
|
|
54
|
+
for part in self.response_path:
|
|
55
|
+
if isinstance(value, dict):
|
|
56
|
+
value = value[part]
|
|
57
|
+
elif isinstance(value, list):
|
|
58
|
+
value = value[int(part)]
|
|
59
|
+
else:
|
|
60
|
+
raise ValueError(f"Cannot resolve response path at: {part}")
|
|
61
|
+
|
|
62
|
+
if not isinstance(value, str):
|
|
63
|
+
raise ValueError("Configured model response path must resolve to text")
|
|
64
|
+
|
|
65
|
+
return ModelResponse(text=value, model_id=model.id, metadata={"adapter": "http"})
|
|
66
|
+
|
|
67
|
+
def _render(self, value: Any, model: ModelSpec, request: ModelRequest) -> Any:
|
|
68
|
+
if isinstance(value, str):
|
|
69
|
+
return (
|
|
70
|
+
value.replace("{model_id}", model.id)
|
|
71
|
+
.replace("{prompt}", request.prompt)
|
|
72
|
+
.replace("{system}", request.system or "")
|
|
73
|
+
)
|
|
74
|
+
if isinstance(value, dict):
|
|
75
|
+
return {key: self._render(item, model, request) for key, item in value.items()}
|
|
76
|
+
if isinstance(value, list):
|
|
77
|
+
return [self._render(item, model, request) for item in value]
|
|
78
|
+
return value
|
runtime/model/invoker.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Model invocation service."""
|
|
2
|
+
|
|
3
|
+
from core.contracts.ai import ModelSpec
|
|
4
|
+
from core.contracts.model_runtime import ModelRequest, ModelResponse
|
|
5
|
+
from runtime.model.registry import ModelAdapterRegistry
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ModelInvoker:
|
|
9
|
+
def __init__(self, registry: ModelAdapterRegistry) -> None:
|
|
10
|
+
self.registry = registry
|
|
11
|
+
|
|
12
|
+
def generate(self, model: ModelSpec, request: ModelRequest) -> ModelResponse:
|
|
13
|
+
adapter_id = model.metadata.get("adapter_id", model.provider_id)
|
|
14
|
+
if not isinstance(adapter_id, str) or not adapter_id:
|
|
15
|
+
raise ValueError(f"Invalid adapter id for model: {model.id}")
|
|
16
|
+
return self.registry.get(adapter_id).generate(model, request)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Materialize declarative provider configuration into executable adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from core.contracts.ai import AIProvider, ModelSpec
|
|
8
|
+
from core.contracts.model_runtime import ModelAdapter
|
|
9
|
+
from runtime.model.credentials import EnvironmentCredentialResolver
|
|
10
|
+
from runtime.model.native import (
|
|
11
|
+
AnthropicMessagesToolAdapter,
|
|
12
|
+
GeminiGenerateContentToolAdapter,
|
|
13
|
+
OpenAIResponsesToolAdapter,
|
|
14
|
+
)
|
|
15
|
+
from runtime.model.registry import ModelAdapterRegistry
|
|
16
|
+
from runtime.policy import ExecutionPolicy
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class ProviderMaterialization:
|
|
21
|
+
"""Executable adapters plus non-secret credential checks."""
|
|
22
|
+
|
|
23
|
+
adapters: ModelAdapterRegistry
|
|
24
|
+
credential_checks: tuple[object, ...]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class NativeProviderMaterializer:
|
|
28
|
+
"""Build provider-native adapters from AIProvider metadata.
|
|
29
|
+
|
|
30
|
+
Provider configuration remains declarative; this class is the only place that
|
|
31
|
+
maps provider kinds to concrete native adapters.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
_CREDENTIALS = {
|
|
35
|
+
"openai": "OPENAI_API_KEY",
|
|
36
|
+
"anthropic": "ANTHROPIC_API_KEY",
|
|
37
|
+
"gemini": "GEMINI_API_KEY",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
*,
|
|
43
|
+
policy: ExecutionPolicy | None = None,
|
|
44
|
+
credentials: EnvironmentCredentialResolver | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
self.policy = policy or ExecutionPolicy()
|
|
47
|
+
self.credentials = credentials or EnvironmentCredentialResolver()
|
|
48
|
+
|
|
49
|
+
def materialize(
|
|
50
|
+
self,
|
|
51
|
+
providers: tuple[AIProvider, ...],
|
|
52
|
+
registry: ModelAdapterRegistry | None = None,
|
|
53
|
+
*,
|
|
54
|
+
validate_credentials: bool = False,
|
|
55
|
+
) -> ProviderMaterialization:
|
|
56
|
+
target = registry or ModelAdapterRegistry()
|
|
57
|
+
checks = []
|
|
58
|
+
for provider in providers:
|
|
59
|
+
kind = provider.kind.lower()
|
|
60
|
+
adapter_id = str(provider.metadata.get("adapter_id", provider.id))
|
|
61
|
+
adapter = self._adapter(kind, provider)
|
|
62
|
+
target.register(adapter_id, adapter)
|
|
63
|
+
for model in provider.models:
|
|
64
|
+
model_adapter_id = str(model.metadata.get("adapter_id", adapter_id))
|
|
65
|
+
if model_adapter_id != adapter_id and model_adapter_id not in target.list():
|
|
66
|
+
target.register(model_adapter_id, adapter)
|
|
67
|
+
if validate_credentials:
|
|
68
|
+
env = str(metadata_env(provider, kind))
|
|
69
|
+
if env:
|
|
70
|
+
checks.extend(self.credentials.check([env]))
|
|
71
|
+
return ProviderMaterialization(target, tuple(checks))
|
|
72
|
+
|
|
73
|
+
def _adapter(self, kind: str, provider: AIProvider) -> ModelAdapter:
|
|
74
|
+
metadata = provider.metadata
|
|
75
|
+
policy = self.policy
|
|
76
|
+
if kind in {"openai", "openai_responses"}:
|
|
77
|
+
return OpenAIResponsesToolAdapter(
|
|
78
|
+
api_key_env=str(metadata.get("api_key_env", "OPENAI_API_KEY")),
|
|
79
|
+
endpoint=str(metadata.get("endpoint", "https://api.openai.com/v1/responses")),
|
|
80
|
+
policy=policy,
|
|
81
|
+
)
|
|
82
|
+
if kind in {"anthropic", "anthropic_messages"}:
|
|
83
|
+
return AnthropicMessagesToolAdapter(
|
|
84
|
+
api_key_env=str(metadata.get("api_key_env", "ANTHROPIC_API_KEY")),
|
|
85
|
+
endpoint=str(metadata.get("endpoint", "https://api.anthropic.com/v1/messages")),
|
|
86
|
+
api_version=str(metadata.get("api_version", "2023-06-01")),
|
|
87
|
+
max_tokens=int(metadata.get("max_tokens", 4096)),
|
|
88
|
+
policy=policy,
|
|
89
|
+
)
|
|
90
|
+
if kind in {"gemini", "gemini_generate_content"}:
|
|
91
|
+
return GeminiGenerateContentToolAdapter(
|
|
92
|
+
api_key_env=str(metadata.get("api_key_env", "GEMINI_API_KEY")),
|
|
93
|
+
endpoint_base=str(metadata.get("endpoint_base", "https://generativelanguage.googleapis.com/v1beta/models")),
|
|
94
|
+
policy=policy,
|
|
95
|
+
)
|
|
96
|
+
raise ValueError(f"Unsupported native provider kind: {provider.kind}")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def metadata_env(provider: AIProvider, kind: str) -> str:
|
|
100
|
+
default = NativeProviderMaterializer._CREDENTIALS.get(kind, "")
|
|
101
|
+
return str(provider.metadata.get("api_key_env", default))
|
runtime/model/native.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Native HTTP provider adapters for VYRELON Tool Calling."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import urllib.request
|
|
8
|
+
from urllib.error import HTTPError, URLError
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any
|
|
11
|
+
from core.contracts.ai import ModelSpec
|
|
12
|
+
from core.contracts.model_runtime import ModelRequest, ModelResponse
|
|
13
|
+
from core.contracts.vyrelon_runtime import ToolSpec
|
|
14
|
+
from runtime.policy import ExecutionPolicy
|
|
15
|
+
|
|
16
|
+
def _tool_name(name: str) -> str:
|
|
17
|
+
return re.sub(r"[^A-Za-z0-9_-]", "_", name)[:64] or "tool"
|
|
18
|
+
|
|
19
|
+
def _tool_defs(tools: tuple[ToolSpec, ...]) -> tuple[list[dict[str, Any]], dict[str, str]]:
|
|
20
|
+
aliases, used, out = {}, set(), []
|
|
21
|
+
for tool in tools:
|
|
22
|
+
alias, base = _tool_name(tool.id), _tool_name(tool.id)
|
|
23
|
+
index = 2
|
|
24
|
+
while alias in used:
|
|
25
|
+
alias = f"{base}_{index}"
|
|
26
|
+
index += 1
|
|
27
|
+
used.add(alias)
|
|
28
|
+
aliases[alias] = tool.id
|
|
29
|
+
out.append({"name": alias, "description": tool.description, "parameters": tool.input_schema or {"type": "object", "properties": {}}})
|
|
30
|
+
return out, aliases
|
|
31
|
+
|
|
32
|
+
def _request(endpoint: str, payload: dict[str, Any], headers: dict[str, str], policy: ExecutionPolicy) -> tuple[dict[str, Any], dict[str, str]]:
|
|
33
|
+
if not policy.permits("network"):
|
|
34
|
+
raise PermissionError("Network model execution is disabled by policy")
|
|
35
|
+
req = urllib.request.Request(endpoint, data=json.dumps(payload).encode(), headers={"Content-Type": "application/json", **headers}, method="POST")
|
|
36
|
+
try:
|
|
37
|
+
with urllib.request.urlopen(req, timeout=300) as response:
|
|
38
|
+
data = json.loads(response.read().decode())
|
|
39
|
+
response_headers = {str(k).lower(): str(v) for k, v in response.headers.items()}
|
|
40
|
+
except HTTPError as exc:
|
|
41
|
+
raise RuntimeError(f"provider request failed with HTTP {exc.code}") from exc
|
|
42
|
+
except URLError as exc:
|
|
43
|
+
raise RuntimeError("provider request failed: network error") from exc
|
|
44
|
+
if not isinstance(data, dict):
|
|
45
|
+
raise ValueError("provider response must be a JSON object")
|
|
46
|
+
return data, response_headers
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class OpenAIResponsesToolAdapter:
|
|
50
|
+
api_key_env: str = "OPENAI_API_KEY"
|
|
51
|
+
endpoint: str = "https://api.openai.com/v1/responses"
|
|
52
|
+
policy: ExecutionPolicy = ExecutionPolicy(allow_network=True)
|
|
53
|
+
def generate(self, model: ModelSpec, request: ModelRequest) -> ModelResponse:
|
|
54
|
+
return self._generate(model, request, ())
|
|
55
|
+
def generate_with_tools(self, model: ModelSpec, request: ModelRequest, tools: tuple[ToolSpec, ...]) -> ModelResponse:
|
|
56
|
+
return self._generate(model, request, tools)
|
|
57
|
+
def _generate(self, model: ModelSpec, request: ModelRequest, tools: tuple[ToolSpec, ...]) -> ModelResponse:
|
|
58
|
+
key = os.environ.get(self.api_key_env)
|
|
59
|
+
if not key: raise RuntimeError(f"Required model credential environment variable is missing: {self.api_key_env}")
|
|
60
|
+
definitions, aliases = _tool_defs(tools)
|
|
61
|
+
input_items: list[dict[str, Any]] = [{"role": "user", "content": request.prompt}]
|
|
62
|
+
history = request.metadata.get("tool_history", ())
|
|
63
|
+
for turn in history if isinstance(history, (list, tuple)) else ():
|
|
64
|
+
for call in turn.get("tool_calls", ()):
|
|
65
|
+
input_items.append({"type": "function_call", "call_id": call["call_id"], "name": _tool_name(str(call["tool_id"])), "arguments": json.dumps(call["arguments"])})
|
|
66
|
+
for result in turn.get("tool_results", ()):
|
|
67
|
+
input_items.append({"type": "function_call_output", "call_id": result["call_id"], "output": json.dumps(result.get("output")) if result.get("ok") else json.dumps({"error": result.get("error")})})
|
|
68
|
+
payload: dict[str, Any] = {"model": model.id, "input": input_items}
|
|
69
|
+
if request.system: payload["instructions"] = request.system
|
|
70
|
+
if definitions: payload["tools"] = [{"type": "function", "name": d["name"], "description": d["description"], "parameters": d["parameters"]} for d in definitions]
|
|
71
|
+
data, response_headers = _request(self.endpoint, payload, {"Authorization": f"Bearer {key}"}, self.policy)
|
|
72
|
+
calls, texts = [], []
|
|
73
|
+
for item in data.get("output", ()):
|
|
74
|
+
if item.get("type") == "function_call":
|
|
75
|
+
calls.append({"id": item.get("call_id", item.get("id")), "name": aliases.get(item.get("name"), item.get("name")), "arguments": json.loads(item.get("arguments", "{}"))})
|
|
76
|
+
elif item.get("type") == "message":
|
|
77
|
+
texts.extend(part.get("text", "") for part in item.get("content", ()) if part.get("type") == "output_text")
|
|
78
|
+
rate_limits = _openai_rate_limits(response_headers)
|
|
79
|
+
usage = _usage_dict(data.get("usage"))
|
|
80
|
+
metadata = {"adapter": "openai_responses", "tool_calls": calls, "provider_response_id": data.get("id")}
|
|
81
|
+
if rate_limits: metadata["rate_limits"] = rate_limits
|
|
82
|
+
if usage: metadata["usage"] = usage
|
|
83
|
+
return ModelResponse("".join(texts), model.id, metadata)
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class AnthropicMessagesToolAdapter:
|
|
87
|
+
api_key_env: str = "ANTHROPIC_API_KEY"
|
|
88
|
+
endpoint: str = "https://api.anthropic.com/v1/messages"
|
|
89
|
+
api_version: str = "2023-06-01"
|
|
90
|
+
max_tokens: int = 4096
|
|
91
|
+
policy: ExecutionPolicy = ExecutionPolicy(allow_network=True)
|
|
92
|
+
def generate(self, model: ModelSpec, request: ModelRequest) -> ModelResponse:
|
|
93
|
+
return self._generate(model, request, ())
|
|
94
|
+
def generate_with_tools(self, model: ModelSpec, request: ModelRequest, tools: tuple[ToolSpec, ...]) -> ModelResponse:
|
|
95
|
+
return self._generate(model, request, tools)
|
|
96
|
+
def _generate(self, model: ModelSpec, request: ModelRequest, tools: tuple[ToolSpec, ...]) -> ModelResponse:
|
|
97
|
+
key = os.environ.get(self.api_key_env)
|
|
98
|
+
if not key: raise RuntimeError(f"Required model credential environment variable is missing: {self.api_key_env}")
|
|
99
|
+
definitions, aliases = _tool_defs(tools)
|
|
100
|
+
messages: list[dict[str, Any]] = [{"role": "user", "content": request.prompt}]
|
|
101
|
+
history = request.metadata.get("tool_history", ())
|
|
102
|
+
for turn in history if isinstance(history, (list, tuple)) else ():
|
|
103
|
+
assistant = [{"type": "tool_use", "id": c["call_id"], "name": _tool_name(str(c["tool_id"])), "input": c["arguments"]} for c in turn.get("tool_calls", ())]
|
|
104
|
+
if assistant: messages.append({"role": "assistant", "content": assistant})
|
|
105
|
+
results = [{"type": "tool_result", "tool_use_id": r["call_id"], "content": json.dumps(r.get("output")) if r.get("ok") else json.dumps({"error": r.get("error")}), "is_error": not r.get("ok", False)} for r in turn.get("tool_results", ())]
|
|
106
|
+
if results: messages.append({"role": "user", "content": results})
|
|
107
|
+
payload: dict[str, Any] = {"model": model.id, "max_tokens": self.max_tokens, "messages": messages}
|
|
108
|
+
if request.system: payload["system"] = request.system
|
|
109
|
+
if definitions: payload["tools"] = [{"name": d["name"], "description": d["description"], "input_schema": d["parameters"]} for d in definitions]
|
|
110
|
+
data, response_headers = _request(self.endpoint, payload, {"x-api-key": key, "anthropic-version": self.api_version}, self.policy)
|
|
111
|
+
calls, texts = [], []
|
|
112
|
+
for block in data.get("content", ()):
|
|
113
|
+
if block.get("type") == "tool_use": calls.append({"id": block["id"], "name": aliases.get(block["name"], block["name"]), "arguments": block.get("input", {})})
|
|
114
|
+
elif block.get("type") == "text": texts.append(block.get("text", ""))
|
|
115
|
+
rate_limits = _anthropic_rate_limits(response_headers)
|
|
116
|
+
usage = _usage_dict(data.get("usage"))
|
|
117
|
+
metadata = {"adapter": "anthropic_messages", "tool_calls": calls, "stop_reason": data.get("stop_reason")}
|
|
118
|
+
if rate_limits: metadata["rate_limits"] = rate_limits
|
|
119
|
+
if usage: metadata["usage"] = usage
|
|
120
|
+
return ModelResponse("".join(texts), model.id, metadata)
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class GeminiGenerateContentToolAdapter:
|
|
124
|
+
api_key_env: str = "GEMINI_API_KEY"
|
|
125
|
+
endpoint_base: str = "https://generativelanguage.googleapis.com/v1beta/models"
|
|
126
|
+
policy: ExecutionPolicy = ExecutionPolicy(allow_network=True)
|
|
127
|
+
def generate(self, model: ModelSpec, request: ModelRequest) -> ModelResponse:
|
|
128
|
+
return self._generate(model, request, ())
|
|
129
|
+
def generate_with_tools(self, model: ModelSpec, request: ModelRequest, tools: tuple[ToolSpec, ...]) -> ModelResponse:
|
|
130
|
+
return self._generate(model, request, tools)
|
|
131
|
+
def _generate(self, model: ModelSpec, request: ModelRequest, tools: tuple[ToolSpec, ...]) -> ModelResponse:
|
|
132
|
+
key = os.environ.get(self.api_key_env)
|
|
133
|
+
if not key: raise RuntimeError(f"Required model credential environment variable is missing: {self.api_key_env}")
|
|
134
|
+
definitions, aliases = _tool_defs(tools)
|
|
135
|
+
contents: list[dict[str, Any]] = [{"role": "user", "parts": [{"text": request.prompt}]}]
|
|
136
|
+
history = request.metadata.get("tool_history", ())
|
|
137
|
+
for turn in history if isinstance(history, (list, tuple)) else ():
|
|
138
|
+
calls = [{"functionCall": {"name": _tool_name(str(c["tool_id"])), "args": c["arguments"]}} for c in turn.get("tool_calls", ())]
|
|
139
|
+
if calls: contents.append({"role": "model", "parts": calls})
|
|
140
|
+
results = [{"functionResponse": {"name": _tool_name(str(r["tool_id"])), "response": {"ok": r.get("ok"), "output": r.get("output"), "error": r.get("error")}}} for r in turn.get("tool_results", ())]
|
|
141
|
+
if results: contents.append({"role": "user", "parts": results})
|
|
142
|
+
payload: dict[str, Any] = {"contents": contents}
|
|
143
|
+
if request.system: payload["systemInstruction"] = {"parts": [{"text": request.system}]}
|
|
144
|
+
if definitions: payload["tools"] = [{"functionDeclarations": [{"name": d["name"], "description": d["description"], "parameters": d["parameters"]} for d in definitions]}]
|
|
145
|
+
endpoint = f"{self.endpoint_base}/{urllib.parse.quote(model.id, safe='')}:generateContent?key={urllib.parse.quote(key, safe='')}"
|
|
146
|
+
data, response_headers = _request(endpoint, payload, {}, self.policy)
|
|
147
|
+
calls, texts = [], []
|
|
148
|
+
for candidate in data.get("candidates", ()):
|
|
149
|
+
for part in candidate.get("content", {}).get("parts", ()):
|
|
150
|
+
if "functionCall" in part:
|
|
151
|
+
fc = part["functionCall"]; calls.append({"id": fc.get("id", f"call-{len(calls)+1}"), "name": aliases.get(fc.get("name"), fc.get("name")), "arguments": fc.get("args", {})})
|
|
152
|
+
elif "text" in part: texts.append(part["text"])
|
|
153
|
+
usage = _usage_dict(data.get("usageMetadata"))
|
|
154
|
+
metadata = {"adapter": "gemini_generate_content", "tool_calls": calls}
|
|
155
|
+
if usage: metadata["usage"] = usage
|
|
156
|
+
return ModelResponse("".join(texts), model.id, metadata)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _usage_dict(value: object) -> dict[str, int]:
|
|
160
|
+
if not isinstance(value, dict):
|
|
161
|
+
return {}
|
|
162
|
+
aliases = {
|
|
163
|
+
"promptTokenCount": "input_tokens",
|
|
164
|
+
"inputTokenCount": "input_tokens",
|
|
165
|
+
"candidatesTokenCount": "output_tokens",
|
|
166
|
+
"outputTokenCount": "output_tokens",
|
|
167
|
+
"totalTokenCount": "total_tokens",
|
|
168
|
+
"thoughtsTokenCount": "thought_tokens",
|
|
169
|
+
"cachedContentTokenCount": "cached_tokens",
|
|
170
|
+
}
|
|
171
|
+
result: dict[str, int] = {}
|
|
172
|
+
for key, target in aliases.items():
|
|
173
|
+
raw = value.get(key)
|
|
174
|
+
if raw is not None:
|
|
175
|
+
try:
|
|
176
|
+
result[target] = int(raw)
|
|
177
|
+
except (TypeError, ValueError):
|
|
178
|
+
pass
|
|
179
|
+
for key in ("input_tokens", "output_tokens", "total_tokens"):
|
|
180
|
+
raw = value.get(key)
|
|
181
|
+
if raw is not None:
|
|
182
|
+
try:
|
|
183
|
+
result[key] = int(raw)
|
|
184
|
+
except (TypeError, ValueError):
|
|
185
|
+
pass
|
|
186
|
+
return result
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _openai_rate_limits(headers: dict[str, str]) -> dict[str, dict[str, object]]:
|
|
190
|
+
result: dict[str, dict[str, object]] = {}
|
|
191
|
+
pairs = (
|
|
192
|
+
("requests", "x-ratelimit-limit-requests", "x-ratelimit-remaining-requests", "x-ratelimit-reset-requests"),
|
|
193
|
+
("tokens", "x-ratelimit-limit-tokens", "x-ratelimit-remaining-tokens", "x-ratelimit-reset-tokens"),
|
|
194
|
+
)
|
|
195
|
+
for name, limit_key, remaining_key, reset_key in pairs:
|
|
196
|
+
if limit_key not in headers and remaining_key not in headers:
|
|
197
|
+
continue
|
|
198
|
+
result[name] = {
|
|
199
|
+
"limit": _header_number(headers.get(limit_key)),
|
|
200
|
+
"remaining": _header_number(headers.get(remaining_key)),
|
|
201
|
+
"reset_seconds": _reset_seconds(headers.get(reset_key)),
|
|
202
|
+
"source": "openai_response_headers",
|
|
203
|
+
}
|
|
204
|
+
return result
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _anthropic_rate_limits(headers: dict[str, str]) -> dict[str, dict[str, object]]:
|
|
208
|
+
result: dict[str, dict[str, object]] = {}
|
|
209
|
+
mappings = (
|
|
210
|
+
("requests", "anthropic-ratelimit-requests-limit", "anthropic-ratelimit-requests-remaining", "anthropic-ratelimit-requests-reset"),
|
|
211
|
+
("input_tokens", "anthropic-ratelimit-input-tokens-limit", "anthropic-ratelimit-input-tokens-remaining", "anthropic-ratelimit-input-tokens-reset"),
|
|
212
|
+
("output_tokens", "anthropic-ratelimit-output-tokens-limit", "anthropic-ratelimit-output-tokens-remaining", "anthropic-ratelimit-output-tokens-reset"),
|
|
213
|
+
)
|
|
214
|
+
for name, limit_key, remaining_key, reset_key in mappings:
|
|
215
|
+
if limit_key not in headers and remaining_key not in headers:
|
|
216
|
+
continue
|
|
217
|
+
result[name] = {
|
|
218
|
+
"limit": _header_number(headers.get(limit_key)),
|
|
219
|
+
"remaining": _header_number(headers.get(remaining_key)),
|
|
220
|
+
"reset_at": headers.get(reset_key),
|
|
221
|
+
"source": "anthropic_response_headers",
|
|
222
|
+
}
|
|
223
|
+
return result
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _header_number(value: object) -> int | None:
|
|
227
|
+
try:
|
|
228
|
+
return int(float(str(value))) if value is not None else None
|
|
229
|
+
except (TypeError, ValueError):
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _reset_seconds(value: object) -> float | None:
|
|
234
|
+
if value is None:
|
|
235
|
+
return None
|
|
236
|
+
text = str(value).strip()
|
|
237
|
+
try:
|
|
238
|
+
if text.endswith("ms"):
|
|
239
|
+
return float(text[:-2]) / 1000
|
|
240
|
+
if text.endswith("s"):
|
|
241
|
+
return float(text[:-1])
|
|
242
|
+
return float(text)
|
|
243
|
+
except ValueError:
|
|
244
|
+
return None
|