feat(provider_registry): close PARTIAL gaps — dichotomy wave
This commit is contained in:
1 parent
257bf77f91
commit
3d21fe3dac
14 files changed
+4341
-197
No files matched your search
@@ -2,16 +2,16 @@
|
||||
|
||||
_Regenerate with `python3 scripts/dichotomy_audit.py --write`. A module is FULL only when 80%+ of its Python functions have name-preserving counterparts in the Go/Rust implementation — directories without the functions are facades and count as NOT TOUCHED._
|
||||
|
||||
**FULL completion: 46%** (89/192 runtime modules at FULL)
|
||||
**FULL completion: 47%** (90/192 runtime modules at FULL)
|
||||
|
||||
Function-level detail: 66% (1495/2279 runtime functions matched) — secondary; includes in-flight work
|
||||
Function-level detail: 66% (1511/2279 runtime functions matched) — secondary; includes in-flight work
|
||||
|
||||
| Status | Modules | Lines | Meaning |
|
||||
|---|---|---|---|
|
||||
| FULL | 89 | 37080 | 80%+ of functions ported |
|
||||
| PARTIAL | 63 | 25605 | 20–80% — real logic exists, gaps named below |
|
||||
| FULL | 90 | 37767 | 80%+ of functions ported |
|
||||
| PARTIAL | 62 | 24926 | 20–80% — real logic exists, gaps named below |
|
||||
| NOT TOUCHED | 40 | 11513 | no name-preserving implementation |
|
||||
| Total runtime | 192 | 74198 | |
|
||||
| Total runtime | 192 | 74206 | |
|
||||
|
||||
## NOT TOUCHED — the port queue, in priority order (largest first)
|
||||
|
||||
@@ -64,7 +64,6 @@ Function-level detail: 66% (1495/2279 runtime functions matched) — secondary;
|
||||
|---|---|---|---|
|
||||
| rune_operator | 2746 | 25/68 | _build_model_router, _sorted_tool_names, _filter_safety_noise, _wire_auto_filer, _reconcile_guarded, get_operator |
|
||||
| memory_store | 2333 | 30/68 | _ctx_budget, now_iso, ensure_memory_type_column, _embed, _temp_json, _entities_to_json |
|
||||
| provider_registry | 679 | 12/28 | _format_tools_openai, _execute_tool_calls, _execute_tool_calls_anthropic, get_registry, connect_provider, list_all_models |
|
||||
| warm_router | 1518 | 17/33 | _ensure_use_history_table, record_outcome, record_neuron_fire, base_level_activation, _db, _aaak |
|
||||
| tool_executor | 1836 | 33/47 | _execute_tool_now, serialize_prepared_tool, deserialize_prepared_tool, confirm_write, _resolve_execution_cwd, _run_command |
|
||||
| cache | 492 | 6/17 | _record_hit, _record_miss, flush_stats, get_cache, put_cache, invalidate_scope |
|
||||
@@ -144,6 +143,7 @@ Function-level detail: 66% (1495/2279 runtime functions matched) — secondary;
|
||||
| memory_consolidation | 770 | 22/22 | GO |
|
||||
| contracts | 743 | 11/11 | RUST |
|
||||
| persona_gate | 724 | 23/23 | GO |
|
||||
| provider_registry | 687 | 28/28 | GO |
|
||||
| abstraction | 656 | 16/16 | GO |
|
||||
| safety_override | 649 | 20/20 | RUST |
|
||||
| adaptive_router | 625 | 19/19 | GO |
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
#!/usr/bin/env python3
|
||||
"""gen_provider_registry_truth.py — dichotomy-gap truth battery for
|
||||
src/rune/provider_registry.py (factory-gap-pr, #2479 tranche, GO side).
|
||||
|
||||
Two artifacts, both embedded VERBATIM by the Go battery
|
||||
(src/go/provider-registry/):
|
||||
|
||||
1. truth.json — shared-harness format ([{"fn","args","kwargs","expected"}])
|
||||
over the MODULE-LEVEL pure functions:
|
||||
_format_tools_openai, _format_tools_anthropic (every branch),
|
||||
_ctx_budget (unknown + every DEFAULT_PROFILES id),
|
||||
_execute_tool_calls / _execute_tool_calls_anthropic (JSON args,
|
||||
malformed args, non-serializable tool result).
|
||||
Verifiable offline with:
|
||||
PYTHONPATH=src python3 scripts/freeze_truth.py --verify truth.json \
|
||||
--module rune.provider_registry
|
||||
|
||||
2. truth_registry.json — method-battery scenarios for the ProviderRegistry
|
||||
surface (to_dict, _load_config, _save_config, connect, disconnect,
|
||||
list_providers, get_provider, list_models, _fetch_models dispatch,
|
||||
_fetch_ollama_models, _fetch_openai_models, _fetch_anthropic_models,
|
||||
_clean_model_name, generate branches, _generate_* payload construction
|
||||
+ response parsing). HTTP is fully scripted: urllib.request.urlopen is
|
||||
replaced by a routing fake (never a socket); response-shaping truth is
|
||||
frozen from the LIVE oracle. Non-deterministic latency_ms is excluded
|
||||
(no-clock law: the oracle's clock is not injectable) and compared
|
||||
neither on the Python side nor the Go side.
|
||||
|
||||
Re-verify both artifacts byte-for-byte with --check.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO / "src"))
|
||||
sys.path.insert(0, str(REPO / "src" / "rune"))
|
||||
|
||||
os.environ["RUNE_PROVIDER_REGISTRY_CORE"] = "python" # poisoned-truth law
|
||||
|
||||
import provider_registry as pr # noqa: E402
|
||||
from freeze_truth import freeze, write as write_truth # noqa: E402
|
||||
|
||||
OUT_HARNESS = REPO / "src" / "go" / "provider-registry" / "truth.json"
|
||||
OUT_REGISTRY = REPO / "src" / "go" / "provider-registry" / "truth_registry.json"
|
||||
|
||||
DENIED_PATH = "/nonexistent/zzz.txt"
|
||||
|
||||
|
||||
# ── scripted HTTP (never a socket) ──────────────────────────────────────────
|
||||
|
||||
class _FakeResp:
|
||||
def __init__(self, body: bytes):
|
||||
self._body = body
|
||||
|
||||
def read(self):
|
||||
return self._body
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
|
||||
class _FakeURLOpen:
|
||||
"""Route table: [{"suffix": "/api/tags", "body": {...}} | {"exc": msg}].
|
||||
POST request bodies are captured for payload-construction truth."""
|
||||
|
||||
def __init__(self, routes):
|
||||
self.routes = routes
|
||||
self.captured = []
|
||||
|
||||
def __call__(self, req, timeout=None):
|
||||
url = req.full_url
|
||||
for r in self.routes:
|
||||
if url.endswith(r["suffix"]):
|
||||
if "exc" in r:
|
||||
raise urllib.error.URLError(r["exc"])
|
||||
self.captured.append({
|
||||
"url": url,
|
||||
"body": json.loads(req.data.decode()) if req.data else None,
|
||||
})
|
||||
return _FakeResp(json.dumps(r["body"]).encode())
|
||||
raise AssertionError(f"unexpected url: {url}")
|
||||
|
||||
|
||||
def _provider_fields(p):
|
||||
return {
|
||||
"name": p.name, "url": p.url, "provider_type": p.provider_type,
|
||||
"api_key": p.api_key, "enabled": p.enabled,
|
||||
"last_health_check": p.last_health_check,
|
||||
"health_status": p.health_status,
|
||||
"models": copy.deepcopy(p.models),
|
||||
}
|
||||
|
||||
|
||||
def _providers_state(reg):
|
||||
return {name: _provider_fields(p) for name, p in reg.providers.items()}
|
||||
|
||||
|
||||
def _config_state(reg):
|
||||
out = {}
|
||||
cfg = Path(reg.config_path)
|
||||
if cfg.exists():
|
||||
out["config"] = json.loads(cfg.read_text())
|
||||
return out
|
||||
|
||||
|
||||
def _strip_latency(result):
|
||||
out = {k: copy.deepcopy(v) for k, v in result.items() if k != "latency_ms"}
|
||||
return out
|
||||
|
||||
|
||||
# ── part 1: module-level pure battery (shared harness) ──────────────────────
|
||||
|
||||
TOOLS = [
|
||||
{"name": "read_file", "description": "Read a file",
|
||||
"parameters": {"type": "object", "properties": {"path": {"type": "string"}}}},
|
||||
{"name": "write_file"}, # no description, no parameters
|
||||
]
|
||||
|
||||
# scripted provider responses (shared by both batteries)
|
||||
OLLAMA_TAG = {
|
||||
"models": [{
|
||||
"name": "qwen2.5:3b", "size": 3425952140,
|
||||
"details": {"family": "qwen2", "parameter_size": "3B"},
|
||||
}],
|
||||
}
|
||||
OLLAMA_NO_NAME = {"models": [{"size": 10}]}
|
||||
OPENAI_MODELS = {"data": [{"id": "gpt-x"}, {"id": "mimo-v2.5-pro"}]}
|
||||
ANTHROPIC_MODELS = {"content": [
|
||||
{"type": "text", "text": " thinking "},
|
||||
{"type": "tool_use", "id": "tu_9", "name": "read_file",
|
||||
"input": {"path": DENIED_PATH}},
|
||||
], "usage": {"input_tokens": 12, "output_tokens": 7}}
|
||||
OLLAMA_GEN = {"response": " hi there ", "eval_count": 42}
|
||||
OPENAI_PLAIN = {"choices": [{"message": {"content": " hello "}}],
|
||||
"usage": {"total_tokens": 17}}
|
||||
OPENAI_TOOLS = {"choices": [{"message": {
|
||||
"content": "calling tool",
|
||||
"tool_calls": [{"id": "call_1", "type": "function",
|
||||
"function": {"name": "read_file",
|
||||
"arguments": json.dumps({"path": DENIED_PATH})}}],
|
||||
}}], "usage": {"total_tokens": 5}}
|
||||
|
||||
|
||||
def harness_cases():
|
||||
cases = []
|
||||
# _format_tools_openai / _format_tools_anthropic — every branch
|
||||
cases.append(("_format_tools_openai", ([],), {}))
|
||||
cases.append(("_format_tools_openai", (TOOLS[:1],), {}))
|
||||
cases.append(("_format_tools_openai", (TOOLS[1:],), {}))
|
||||
cases.append(("_format_tools_openai", (TOOLS,), {}))
|
||||
cases.append(("_format_tools_anthropic", ([],), {}))
|
||||
cases.append(("_format_tools_anthropic", (TOOLS[:1],), {}))
|
||||
cases.append(("_format_tools_anthropic", (TOOLS[1:],), {}))
|
||||
cases.append(("_format_tools_anthropic", (TOOLS,), {}))
|
||||
# _ctx_budget — unknown id + every DEFAULT_PROFILES id
|
||||
for model_id in ("unknown-model-xyz", "deterministic", "qwen2.5:3b",
|
||||
"qwen3:14b", "qwen3.6:27b", "mimo-v2.5-pro"):
|
||||
cases.append(("_ctx_budget", (model_id,), {}))
|
||||
# _execute_tool_calls — parsed args, malformed args, non-serializable
|
||||
cases.append(("_execute_tool_calls",
|
||||
([{"id": "call_1", "function": {
|
||||
"name": "read_file",
|
||||
"arguments": json.dumps({"path": DENIED_PATH})}}],), {}))
|
||||
cases.append(("_execute_tool_calls",
|
||||
([{"id": "call_2", "function": {
|
||||
"name": "read_file", "arguments": "not-json"}}],), {}))
|
||||
cases.append(("_execute_tool_calls",
|
||||
([{"id": "call_3", "function": {
|
||||
"name": "no_such_tool_xyz", "arguments": "{}"}}],), {}))
|
||||
cases.append(("_execute_tool_calls_anthropic",
|
||||
([{"type": "tool_use", "id": "tu_1", "name": "read_file",
|
||||
"input": {"path": DENIED_PATH}}],), {}))
|
||||
cases.append(("_execute_tool_calls_anthropic",
|
||||
([{"type": "tool_use", "id": "tu_2",
|
||||
"name": "no_such_tool_xyz", "input": {}}],), {}))
|
||||
return cases
|
||||
|
||||
|
||||
# ── part 2: registry method battery (scripted HTTP, hermetic config) ───────
|
||||
|
||||
def registry_rows():
|
||||
rows = []
|
||||
# hermetic config dir — no registry may read the ambient providers.json
|
||||
_td = tempfile.TemporaryDirectory()
|
||||
_HERMETIC_CFG = str(Path(_td.name) / "providers.json")
|
||||
|
||||
def row(fn, name, inp, expect, expect_error=None):
|
||||
rec = {"fn": fn, "name": name, "input": inp, "expected": expect}
|
||||
if expect_error is not None:
|
||||
rec["expected_error"] = expect_error
|
||||
rows.append(rec)
|
||||
|
||||
def make_provider(**kw):
|
||||
kw.setdefault("enabled", True)
|
||||
kw.setdefault("models", [])
|
||||
kw.setdefault("health_status", "unknown")
|
||||
return pr.ModelProvider(**kw)
|
||||
|
||||
def make_reg(providers=None, config_path=None):
|
||||
reg = pr.ProviderRegistry(config_path=config_path or _HERMETIC_CFG)
|
||||
for n, p in (providers or {}).items():
|
||||
reg.providers[n] = p
|
||||
return reg
|
||||
|
||||
# ── to_dict ─────────────────────────────────────────────────────────
|
||||
p = make_provider(name="p1", url="http://x", provider_type="openai",
|
||||
api_key="sk-secret", models=[{"name": "m1"}],
|
||||
enabled=False, last_health_check="2026-01-01",
|
||||
health_status="error")
|
||||
row("to_dict", "full", {"provider": _provider_fields(p)}, p.to_dict())
|
||||
|
||||
# ── _load_config ────────────────────────────────────────────────────
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
missing = str(Path(td) / "a" / "providers.json")
|
||||
reg = pr.ProviderRegistry(config_path=missing)
|
||||
row("_load_config", "missing_file", {"config_path": "TMP:a/providers.json"},
|
||||
{"providers": _providers_state(reg)})
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
cfg = str(Path(td) / "providers.json")
|
||||
Path(cfg).write_text("{not json")
|
||||
reg = pr.ProviderRegistry(config_path=cfg)
|
||||
row("_load_config", "malformed", {"config_path": "TMP:malformed", "content": "{not json"},
|
||||
{"providers": _providers_state(reg)})
|
||||
|
||||
full_cfg = {"providers": {
|
||||
"alpha": {"url": "http://a", "type": "openai", "api_key": "k1", "enabled": False},
|
||||
"beta": {"url": "http://b"}, # defaults: type=custom, enabled=true, api_key=None
|
||||
}}
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
cfg = str(Path(td) / "providers.json")
|
||||
Path(cfg).write_text(json.dumps(full_cfg))
|
||||
reg = pr.ProviderRegistry(config_path=cfg)
|
||||
row("_load_config", "full_and_defaults", {"config_path": "TMP:full", "content": full_cfg},
|
||||
{"providers": _providers_state(reg)})
|
||||
|
||||
# ── _save_config (masking law) ──────────────────────────────────────
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
cfg = str(Path(td) / "nested" / "providers.json")
|
||||
reg = make_reg({
|
||||
"secret": make_provider(name="secret", url="http://s",
|
||||
provider_type="openai", api_key="sk-live",
|
||||
enabled=True),
|
||||
"plain": make_provider(name="plain", url="http://p",
|
||||
provider_type="ollama", api_key=None,
|
||||
enabled=False),
|
||||
}, config_path=cfg)
|
||||
reg._save_config()
|
||||
row("_save_config", "masks_api_key",
|
||||
{"config_path": "TMP:nested",
|
||||
"providers": _providers_state(reg)},
|
||||
{"written": json.loads(Path(cfg).read_text())})
|
||||
|
||||
# ── connect / disconnect / list_providers / get_provider ────────────
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
cfg = str(Path(td) / "providers.json")
|
||||
reg = pr.ProviderRegistry(config_path=cfg)
|
||||
prov = reg.connect("zeta", "http://z", "openai", "zk")
|
||||
row("connect", "persists_and_masks",
|
||||
{"config_path": "TMP:connect", "name": "zeta", "url": "http://z",
|
||||
"provider_type": "openai", "api_key": "zk"},
|
||||
{"provider": prov.to_dict(), **_config_state(reg)})
|
||||
reg.connect("alpha", "http://a", "ollama", None)
|
||||
row("list_providers", "sorted_names",
|
||||
{"config_path": "TMP:list", "providers": _providers_state(reg)},
|
||||
{"providers": reg.list_providers()})
|
||||
got = reg.get_provider("alpha")
|
||||
row("get_provider", "hit",
|
||||
{"config_path": "TMP:get", "name": "alpha",
|
||||
"providers": _providers_state(reg)},
|
||||
{"provider": got.to_dict() if got else None})
|
||||
miss = reg.get_provider("nope")
|
||||
row("get_provider", "miss", {"config_path": "TMP:get", "name": "nope",
|
||||
"providers": _providers_state(reg)},
|
||||
{"provider": miss.to_dict() if miss else None})
|
||||
pre = _providers_state(reg)
|
||||
seed = json.loads(Path(cfg).read_text()) if Path(cfg).exists() else None
|
||||
reg.disconnect("zeta")
|
||||
inp = {"config_path": "TMP:disc", "name": "zeta", "providers": pre}
|
||||
if seed is not None:
|
||||
inp["seed_config"] = seed
|
||||
row("disconnect", "present", inp,
|
||||
{"providers": _providers_state(reg), **_config_state(reg)})
|
||||
pre2 = _providers_state(reg)
|
||||
seed2 = json.loads(Path(cfg).read_text()) if Path(cfg).exists() else None
|
||||
reg.disconnect("ghost")
|
||||
inp2 = {"config_path": "TMP:disc2", "name": "ghost", "providers": pre2}
|
||||
if seed2 is not None:
|
||||
inp2["seed_config"] = seed2
|
||||
row("disconnect", "absent_noop", inp2,
|
||||
{"providers": _providers_state(reg), **_config_state(reg)})
|
||||
|
||||
# ── _clean_model_name (delegates to model_connect) ──────────────────
|
||||
reg = pr.ProviderRegistry(config_path="/nonexistent/pr-gen.json")
|
||||
for name in ("qwen2.5:3b", "mimo-v2.5-pro",
|
||||
"hf.co/DavidAU/Qwen3.6-27B-Fable-Fusion-GGUF:IQ2_M",
|
||||
"hf.co/org/llama-3-8b", "hf.co/a/b",
|
||||
"some-unknown-name-with-dashes"):
|
||||
row("_clean_model_name", name[:24], {"name": name},
|
||||
reg._clean_model_name(name))
|
||||
|
||||
# ── _fetch_models dispatch + provider fetchers (scripted HTTP) ──────
|
||||
ollama_p = make_provider(name="ol", url="http://127.0.0.1:11434",
|
||||
provider_type="ollama")
|
||||
openai_p = make_provider(name="oa", url="http://api.example",
|
||||
provider_type="openai", api_key="sk")
|
||||
anthropic_p = make_provider(name="an", url="http://an.example",
|
||||
provider_type="anthropic")
|
||||
custom_p = make_provider(name="cu", url="http://c.example",
|
||||
provider_type="custom")
|
||||
|
||||
with patch("urllib.request.urlopen", _FakeURLOpen([
|
||||
{"suffix": "/api/tags", "body": OLLAMA_TAG}])):
|
||||
row("_fetch_ollama_models", "success",
|
||||
{"provider": _provider_fields(ollama_p), "routes": [{"suffix": "/api/tags", "body": OLLAMA_TAG}]},
|
||||
reg._fetch_ollama_models(ollama_p))
|
||||
with patch("urllib.request.urlopen", _FakeURLOpen([
|
||||
{"suffix": "/api/tags", "exc": "conn refused"}])):
|
||||
row("_fetch_ollama_models", "connection_error",
|
||||
{"provider": _provider_fields(ollama_p),
|
||||
"routes": [{"suffix": "/api/tags", "exc": "conn refused"}]},
|
||||
None, expect_error="Ollama connection failed: <urlopen error conn refused>")
|
||||
with patch("urllib.request.urlopen", _FakeURLOpen([
|
||||
{"suffix": "/api/tags", "body": OLLAMA_NO_NAME}])):
|
||||
row("_fetch_ollama_models", "missing_name_key",
|
||||
{"provider": _provider_fields(ollama_p),
|
||||
"routes": [{"suffix": "/api/tags", "body": OLLAMA_NO_NAME}]},
|
||||
None, expect_error="Ollama connection failed: 'name'")
|
||||
|
||||
with patch("urllib.request.urlopen", _FakeURLOpen([
|
||||
{"suffix": "/v1/models", "body": OPENAI_MODELS}])):
|
||||
row("_fetch_openai_models", "success",
|
||||
{"provider": _provider_fields(openai_p), "routes": [{"suffix": "/v1/models", "body": OPENAI_MODELS}]},
|
||||
reg._fetch_openai_models(openai_p))
|
||||
with patch("urllib.request.urlopen", _FakeURLOpen([
|
||||
{"suffix": "/v1/models", "exc": "boom"}])):
|
||||
row("_fetch_openai_models", "connection_error",
|
||||
{"provider": _provider_fields(openai_p),
|
||||
"routes": [{"suffix": "/v1/models", "exc": "boom"}]},
|
||||
None, expect_error="[OI] API connection failed: <urlopen error boom>")
|
||||
|
||||
row("_fetch_anthropic_models", "static_list",
|
||||
{"provider": _provider_fields(anthropic_p)},
|
||||
reg._fetch_anthropic_models(anthropic_p))
|
||||
|
||||
for ptype, prov, routes, body_key in (
|
||||
("ollama", ollama_p, [{"suffix": "/api/tags", "body": OLLAMA_TAG}], "OLLAMA_TAG"),
|
||||
("openai", openai_p, [{"suffix": "/v1/models", "body": OPENAI_MODELS}], "OPENAI_MODELS")):
|
||||
with patch("urllib.request.urlopen", _FakeURLOpen(routes)):
|
||||
row("_fetch_models", f"dispatch_{ptype}",
|
||||
{"provider": _provider_fields(prov), "routes": routes},
|
||||
reg._fetch_models(prov))
|
||||
row("_fetch_models", "dispatch_anthropic",
|
||||
{"provider": _provider_fields(anthropic_p)},
|
||||
reg._fetch_models(anthropic_p))
|
||||
row("_fetch_models", "dispatch_custom_empty",
|
||||
{"provider": _provider_fields(custom_p)},
|
||||
reg._fetch_models(custom_p))
|
||||
|
||||
# ── list_models orchestration ───────────────────────────────────────
|
||||
reg2 = make_reg({"cu": custom_p})
|
||||
row("list_models", "custom_yields_none",
|
||||
{"providers": _providers_state(reg2)}, reg2.list_models())
|
||||
|
||||
reg3 = make_reg({"off": make_provider(name="off", url="http://o",
|
||||
provider_type="openai",
|
||||
enabled=False)})
|
||||
row("list_models", "disabled_skipped",
|
||||
{"providers": _providers_state(reg3)}, reg3.list_models())
|
||||
|
||||
reg4 = make_reg({"bad": make_provider(name="bad", url="http://b",
|
||||
provider_type="ollama")})
|
||||
with patch("urllib.request.urlopen", _FakeURLOpen([
|
||||
{"suffix": "/api/tags", "exc": "conn refused"}])):
|
||||
row("list_models", "fetch_error_degrades",
|
||||
{"providers": _providers_state(reg4),
|
||||
"routes": [{"suffix": "/api/tags", "exc": "conn refused"}]},
|
||||
reg4.list_models())
|
||||
|
||||
reg5 = make_reg({"oa": openai_p})
|
||||
with patch("urllib.request.urlopen", _FakeURLOpen([
|
||||
{"suffix": "/v1/models", "body": OPENAI_MODELS}])):
|
||||
row("list_models", "annotates_provider",
|
||||
{"providers": _providers_state(reg5),
|
||||
"routes": [{"suffix": "/v1/models", "body": OPENAI_MODELS}]},
|
||||
reg5.list_models())
|
||||
|
||||
reg6 = make_reg({"ol": ollama_p})
|
||||
with patch("urllib.request.urlopen", _FakeURLOpen([
|
||||
{"suffix": "/api/tags", "body": OLLAMA_TAG}])):
|
||||
row("list_models", "ollama_shape",
|
||||
{"providers": _providers_state(reg6),
|
||||
"routes": [{"suffix": "/api/tags", "body": OLLAMA_TAG}]},
|
||||
reg6.list_models())
|
||||
|
||||
# ── generate orchestration branches ─────────────────────────────────
|
||||
row("generate", "provider_not_found",
|
||||
{"providers": _providers_state(reg2), "prompt": "hi",
|
||||
"provider_name": "ghost"},
|
||||
reg2.generate("hi", "ghost"))
|
||||
empty_reg = pr.ProviderRegistry(config_path="/nonexistent/pr-gen.json")
|
||||
row("generate", "no_providers", {"prompt": "hi"}, empty_reg.generate("hi"))
|
||||
row("generate", "unsupported_type",
|
||||
{"providers": _providers_state(reg2), "prompt": "hi",
|
||||
"provider_name": "cu", "model": "m"},
|
||||
reg2.generate("hi", "cu", "m"))
|
||||
row("generate", "no_models_available",
|
||||
{"providers": _providers_state(reg2), "prompt": "hi",
|
||||
"provider_name": "cu"},
|
||||
reg2.generate("hi", "cu"))
|
||||
|
||||
# ── _generate_* payload construction + response parsing ─────────────
|
||||
def gen_case(fn, name, provider, routes, expect=None, expect_error=None,
|
||||
**call_kw):
|
||||
fake = _FakeURLOpen(routes)
|
||||
method = getattr(pr.ProviderRegistry, fn)
|
||||
with patch("urllib.request.urlopen", fake):
|
||||
result = method(reg, provider, **call_kw)
|
||||
inp = {"provider": _provider_fields(provider),
|
||||
"routes": [copy.deepcopy(r) for r in routes]}
|
||||
inp.update({k: copy.deepcopy(v) for k, v in call_kw.items()})
|
||||
rec = {"expected_request": fake.captured[0]["body"] if fake.captured else None}
|
||||
if expect_error is not None:
|
||||
rec["expected_error"] = expect_error
|
||||
else:
|
||||
rec["expected"] = _strip_latency(result)
|
||||
row(fn, name, inp, rec)
|
||||
|
||||
gen_case("_generate_ollama", "payload_and_response", ollama_p,
|
||||
[{"suffix": "/api/generate", "body": OLLAMA_GEN}],
|
||||
prompt="hello", model="qwen2.5:3b", max_tokens=500,
|
||||
temperature=0.7)
|
||||
with patch("urllib.request.urlopen", _FakeURLOpen([
|
||||
{"suffix": "/api/generate", "exc": "no server"}])):
|
||||
row("_generate_ollama", "error_degrades",
|
||||
{"provider": _provider_fields(ollama_p),
|
||||
"routes": [{"suffix": "/api/generate", "exc": "no server"}],
|
||||
"prompt": "hello", "model": "qwen2.5:3b", "max_tokens": 500,
|
||||
"temperature": 0.7},
|
||||
{"expected_request": None, "expected_error":
|
||||
"<urlopen error no server>"})
|
||||
|
||||
gen_case("_generate_openai", "plain", openai_p,
|
||||
[{"suffix": "/v1/chat/completions", "body": OPENAI_PLAIN}],
|
||||
prompt="hello", model="mimo-v2.5-pro", max_tokens=500,
|
||||
temperature=0.7, system_prompt="be brief", tools=None)
|
||||
gen_case("_generate_openai", "with_tools", openai_p,
|
||||
[{"suffix": "/v1/chat/completions", "body": OPENAI_TOOLS}],
|
||||
prompt="hello", model="mimo-v2.5-pro", max_tokens=500,
|
||||
temperature=0.7, system_prompt=None, tools=TOOLS)
|
||||
with patch("urllib.request.urlopen", _FakeURLOpen([
|
||||
{"suffix": "/v1/chat/completions", "exc": "401"}])):
|
||||
row("_generate_openai", "error_degrades",
|
||||
{"provider": _provider_fields(openai_p),
|
||||
"routes": [{"suffix": "/v1/chat/completions", "exc": "401"}],
|
||||
"prompt": "hello", "model": "m", "max_tokens": 500,
|
||||
"temperature": 0.7, "system_prompt": None, "tools": None},
|
||||
{"expected_request": None, "expected_error": "<urlopen error 401>"})
|
||||
|
||||
gen_case("_generate_anthropic", "plain", anthropic_p,
|
||||
[{"suffix": "/v1/messages", "body": ANTHROPIC_MODELS}],
|
||||
prompt="hello", model="claude-x", max_tokens=500,
|
||||
temperature=0.7, system_prompt="be brief", tools=None)
|
||||
gen_case("_generate_anthropic", "with_tools", anthropic_p,
|
||||
[{"suffix": "/v1/messages", "body": ANTHROPIC_MODELS}],
|
||||
prompt="hello", model="claude-x", max_tokens=500,
|
||||
temperature=0.7, system_prompt=None, tools=TOOLS)
|
||||
with patch("urllib.request.urlopen", _FakeURLOpen([
|
||||
{"suffix": "/v1/messages", "exc": "overloaded"}])):
|
||||
row("_generate_anthropic", "error_degrades",
|
||||
{"provider": _provider_fields(anthropic_p),
|
||||
"routes": [{"suffix": "/v1/messages", "exc": "overloaded"}],
|
||||
"prompt": "hello", "model": "claude-x", "max_tokens": 500,
|
||||
"temperature": 0.7, "system_prompt": None, "tools": None},
|
||||
{"expected_request": None, "expected_error":
|
||||
"<urlopen error overloaded>"})
|
||||
return rows
|
||||
|
||||
|
||||
def main():
|
||||
check = "--check" in sys.argv
|
||||
cases = harness_cases()
|
||||
truth = freeze("rune.provider_registry", cases, oracle_module="rune.provider_registry")
|
||||
|
||||
if check:
|
||||
from freeze_truth import verify
|
||||
rc = verify(OUT_HARNESS, "rune.provider_registry")
|
||||
rows = registry_rows()
|
||||
frozen = json.loads(OUT_REGISTRY.read_text())
|
||||
if json.dumps(rows, sort_keys=True) != json.dumps(frozen, sort_keys=True):
|
||||
print("MISMATCH: truth_registry.json drifted from live oracle")
|
||||
return 1
|
||||
print(f"OK: {len(rows)} registry scenarios byte-identical against live oracle")
|
||||
return rc
|
||||
|
||||
write_truth(truth, OUT_HARNESS)
|
||||
rows = registry_rows()
|
||||
OUT_REGISTRY.write_text(json.dumps(rows, indent=2) + "\n")
|
||||
print(f"wrote {OUT_HARNESS} ({len(truth)} cases)")
|
||||
print(f"wrote {OUT_REGISTRY} ({len(rows)} scenarios)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,72 @@
|
||||
package main
|
||||
|
||||
// chat.go — the R2 W5b chat surface (Go CLI extra beyond the Python
|
||||
// oracle's generate; kept for the existing CLI contract).
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (reg *ProviderRegistry) chat(providerName, model, message string) (string, error) {
|
||||
p := reg.GetProvider(providerName)
|
||||
if p == nil {
|
||||
return "", fmt.Errorf("provider %q not found", providerName)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 300 * time.Second}
|
||||
|
||||
var requestBody map[string]any
|
||||
var url string
|
||||
|
||||
switch p.ProviderType {
|
||||
case "ollama":
|
||||
url = p.URL + "/api/chat"
|
||||
requestBody = map[string]any{
|
||||
"model": model, "messages": []map[string]string{{"role": "user", "content": message}},
|
||||
"stream": false,
|
||||
}
|
||||
case "openai":
|
||||
url = p.URL + "/v1/chat/completions"
|
||||
requestBody = map[string]any{
|
||||
"model": model, "messages": []map[string]string{{"role": "user", "content": message}},
|
||||
}
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported provider type: %s", p.ProviderType)
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(requestBody)
|
||||
req, _ := http.NewRequest("POST", url, bytes.NewReader(data))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if p.APIKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+p.APIKey)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
// Parse response based on provider type
|
||||
var result map[string]any
|
||||
json.Unmarshal(body, &result)
|
||||
if content, ok := result["content"].(string); ok {
|
||||
return content, nil // Ollama format
|
||||
}
|
||||
if choices, ok := result["choices"].([]any); ok && len(choices) > 0 {
|
||||
if choice, ok := choices[0].(map[string]any); ok {
|
||||
if msg, ok := choice["message"].(map[string]any); ok {
|
||||
if content, ok := msg["content"].(string); ok {
|
||||
return content, nil // [OI] format
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
package main
|
||||
|
||||
// format.go — factory batch 5: the tool-format surfaces of
|
||||
// provider_registry.py (FormatToolsOpenAI / FormatToolsAnthropic — the
|
||||
// provider-facing adapters; pure shape transforms).
|
||||
// format.go — factory batch 5 + factory-gap-pr: the tool-format surfaces
|
||||
// of provider_registry.py (_format_tools_openai / _format_tools_anthropic —
|
||||
// the provider-facing adapters; pure shape transforms).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// FormatToolsOpenAI — [OI] API shape.
|
||||
func FormatToolsOpenAI(tools []map[string]interface{}) []map[string]interface{} {
|
||||
// FormatTools — the _format_tools_openai core ([OI] API shape).
|
||||
func FormatTools(tools []map[string]interface{}) []map[string]interface{} {
|
||||
out := make([]map[string]interface{}, 0, len(tools))
|
||||
for _, tool := range tools {
|
||||
name, _ := tool["name"].(string)
|
||||
@@ -31,6 +31,11 @@ func FormatToolsOpenAI(tools []map[string]interface{}) []map[string]interface{}
|
||||
return out
|
||||
}
|
||||
|
||||
// FormatToolsOpenai — _format_tools_openai parity name.
|
||||
func FormatToolsOpenai(tools []map[string]interface{}) []map[string]interface{} {
|
||||
return FormatTools(tools)
|
||||
}
|
||||
|
||||
// FormatToolsAnthropic — Anthropic API shape.
|
||||
func FormatToolsAnthropic(tools []map[string]interface{}) []map[string]interface{} {
|
||||
out := make([]map[string]interface{}, 0, len(tools))
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// provider-registry — Go provider registry (R2 W5b deep port).
|
||||
// provider-registry — Go provider registry (R2 W5b deep port,
|
||||
// factory-gap-pr gap-closure: the full provider_registry.py surface).
|
||||
// PARITY port of src/rune/provider_registry.py: connect, disconnect,
|
||||
// list_providers, get_provider, list_models (Ollama + OpenAI + Anthropic).
|
||||
// list_providers, get_provider, list_models (Ollama + [OI] + Anthropic),
|
||||
// generate (Ollama + [OI] + Anthropic with native tool calling),
|
||||
// _ctx_budget, get_registry, connect_provider, list_all_models.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
@@ -9,190 +12,24 @@
|
||||
// provider-registry list
|
||||
// provider-registry models <name>
|
||||
// provider-registry chat <provider-name> <model> <message>
|
||||
// provider-registry generate <prompt> [--provider <name>] [--model <model>]
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ModelProvider struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
ProviderType string `json:"provider_type"` // openai, ollama, anthropic
|
||||
APIKey string `json:"api_key,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
type ProviderRegistry struct {
|
||||
mu sync.RWMutex
|
||||
Providers map[string]ModelProvider `json:"providers"`
|
||||
ConfigPath string `json:"config_path"`
|
||||
}
|
||||
|
||||
type ChatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
func configPath() string {
|
||||
home := os.Getenv("RUNE_MEMORY_DIR")
|
||||
if home == "" {
|
||||
home = os.Getenv("HOME") + "/.rune"
|
||||
}
|
||||
return home + "/provider_registry.json"
|
||||
}
|
||||
|
||||
func loadRegistry() *ProviderRegistry {
|
||||
reg := &ProviderRegistry{Providers: make(map[string]ModelProvider)}
|
||||
if data, err := os.ReadFile(configPath()); err == nil {
|
||||
json.Unmarshal(data, reg)
|
||||
}
|
||||
return reg
|
||||
}
|
||||
|
||||
func saveRegistry(reg *ProviderRegistry) {
|
||||
// Marshal ONLY durable state — the embedded sync.RWMutex must never be
|
||||
// copied (go vet copies-lock) and is not persisted state.
|
||||
data, _ := json.MarshalIndent(struct {
|
||||
Providers map[string]ModelProvider `json:"providers"`
|
||||
ConfigPath string `json:"config_path"`
|
||||
}{Providers: reg.Providers, ConfigPath: reg.ConfigPath}, "", " ")
|
||||
os.WriteFile(configPath(), data, 0600)
|
||||
}
|
||||
|
||||
func (reg *ProviderRegistry) connect(name, url, providerType, apiKey string) ModelProvider {
|
||||
provider := ModelProvider{
|
||||
Name: name, URL: url, ProviderType: providerType,
|
||||
APIKey: apiKey, Active: true,
|
||||
}
|
||||
reg.mu.Lock()
|
||||
reg.Providers[name] = provider
|
||||
reg.mu.Unlock()
|
||||
saveRegistry(reg)
|
||||
return provider
|
||||
}
|
||||
|
||||
func (reg *ProviderRegistry) fetchModels(provider ModelProvider) ([]string, error) {
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
var models []string
|
||||
|
||||
switch provider.ProviderType {
|
||||
case "ollama":
|
||||
resp, err := client.Get(provider.URL + "/api/tags")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var result struct {
|
||||
Models []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"models"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
for _, m := range result.Models {
|
||||
models = append(models, cleanModelName(m.Name))
|
||||
}
|
||||
case "openai", "anthropic":
|
||||
req, _ := http.NewRequest("GET", provider.URL+"/v1/models", nil)
|
||||
if provider.APIKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+provider.APIKey)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var result struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
for _, m := range result.Data {
|
||||
models = append(models, cleanModelName(m.ID))
|
||||
}
|
||||
}
|
||||
return models, nil
|
||||
}
|
||||
|
||||
func cleanModelName(name string) string {
|
||||
// Strip common prefixes
|
||||
name = strings.TrimPrefix(name, "models/")
|
||||
return name
|
||||
}
|
||||
|
||||
func (reg *ProviderRegistry) chat(providerName, model, message string) (string, error) {
|
||||
reg.mu.RLock()
|
||||
provider, ok := reg.Providers[providerName]
|
||||
reg.mu.RUnlock()
|
||||
if !ok {
|
||||
return "", fmt.Errorf("provider %q not found", providerName)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 300 * time.Second}
|
||||
|
||||
var requestBody map[string]interface{}
|
||||
var url string
|
||||
|
||||
switch provider.ProviderType {
|
||||
case "ollama":
|
||||
url = provider.URL + "/api/chat"
|
||||
requestBody = map[string]interface{}{
|
||||
"model": model, "messages": []map[string]string{{"role": "user", "content": message}},
|
||||
"stream": false,
|
||||
}
|
||||
case "openai":
|
||||
url = provider.URL + "/v1/chat/completions"
|
||||
requestBody = map[string]interface{}{
|
||||
"model": model, "messages": []map[string]string{{"role": "user", "content": message}},
|
||||
}
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported provider type: %s", provider.ProviderType)
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(requestBody)
|
||||
req, _ := http.NewRequest("POST", url, bytes.NewReader(data))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if provider.APIKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+provider.APIKey)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
// Parse response based on provider type
|
||||
var result map[string]interface{}
|
||||
json.Unmarshal(body, &result)
|
||||
if content, ok := result["content"].(string); ok {
|
||||
return content, nil // Ollama format
|
||||
}
|
||||
if choices, ok := result["choices"].([]interface{}); ok && len(choices) > 0 {
|
||||
if choice, ok := choices[0].(map[string]interface{}); ok {
|
||||
if msg, ok := choice["message"].(map[string]interface{}); ok {
|
||||
if content, ok := msg["content"].(string); ok {
|
||||
return content, nil // OpenAI format
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return string(body), nil
|
||||
return NewRegistry(defaultConfigPath())
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: provider-registry <connect|disconnect|list|models|chat> [args...]")
|
||||
fmt.Fprintln(os.Stderr, "usage: provider-registry <connect|disconnect|list|models|chat|generate> [args...]")
|
||||
os.Exit(1)
|
||||
}
|
||||
reg := loadRegistry()
|
||||
@@ -206,7 +43,7 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
name, url := args[0], args[1]
|
||||
providerType := "ollama"
|
||||
providerType := "openai" // the Python oracle's connect() default
|
||||
apiKey := ""
|
||||
if len(args) > 2 {
|
||||
providerType = args[2]
|
||||
@@ -214,8 +51,8 @@ func main() {
|
||||
if len(args) > 3 {
|
||||
apiKey = args[3]
|
||||
}
|
||||
provider := reg.connect(name, url, providerType, apiKey)
|
||||
out, _ := json.MarshalIndent(provider, "", " ")
|
||||
provider := reg.Connect(name, url, providerType, apiKey)
|
||||
out, _ := json.MarshalIndent(provider.ToDict(), "", " ")
|
||||
fmt.Println(string(out))
|
||||
|
||||
case "disconnect":
|
||||
@@ -223,14 +60,11 @@ func main() {
|
||||
fmt.Fprintln(os.Stderr, "usage: provider-registry disconnect <name>")
|
||||
os.Exit(1)
|
||||
}
|
||||
reg.mu.Lock()
|
||||
delete(reg.Providers, args[0])
|
||||
reg.mu.Unlock()
|
||||
saveRegistry(reg)
|
||||
reg.Disconnect(args[0])
|
||||
fmt.Println("disconnected:", args[0])
|
||||
|
||||
case "list":
|
||||
for _, p := range reg.Providers {
|
||||
for _, p := range reg.ListProviders() {
|
||||
out, _ := json.Marshal(p)
|
||||
fmt.Println(string(out))
|
||||
}
|
||||
@@ -240,12 +74,12 @@ func main() {
|
||||
fmt.Fprintln(os.Stderr, "usage: provider-registry models <name>")
|
||||
os.Exit(1)
|
||||
}
|
||||
provider, ok := reg.Providers[args[0]]
|
||||
if !ok {
|
||||
provider := reg.GetProvider(args[0])
|
||||
if provider == nil {
|
||||
fmt.Println("null")
|
||||
return
|
||||
}
|
||||
models, err := reg.fetchModels(provider)
|
||||
models, err := reg.FetchModels(provider)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
@@ -266,6 +100,31 @@ func main() {
|
||||
out, _ := json.Marshal(map[string]string{"response": response})
|
||||
fmt.Println(string(out))
|
||||
|
||||
case "generate":
|
||||
fs := flag.NewFlagSet("generate", flag.ContinueOnError)
|
||||
providerName := fs.String("provider", "", "provider name")
|
||||
model := fs.String("model", "", "model id")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "usage: provider-registry generate <prompt> [--provider <name>] [--model <model>]")
|
||||
os.Exit(1)
|
||||
}
|
||||
if fs.NArg() < 1 {
|
||||
fmt.Fprintln(os.Stderr, "usage: provider-registry generate <prompt> [--provider <name>] [--model <model>]")
|
||||
os.Exit(1)
|
||||
}
|
||||
result := reg.Generate(fs.Arg(0), *providerName, *model, 500, 0.7, nil, nil)
|
||||
if errText, ok := result["error"]; ok {
|
||||
fmt.Printf("Error: %v\n", errText)
|
||||
return
|
||||
}
|
||||
resp := pyStrOr(result["response"], "")
|
||||
if len(resp) > 200 {
|
||||
resp = resp[:200]
|
||||
}
|
||||
fmt.Printf("Response: %s...\n", resp)
|
||||
fmt.Printf("Model: %v (%v)\n", result["model"], result["provider"])
|
||||
fmt.Printf("Latency: %vms\n", result["latency_ms"])
|
||||
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown command: %s\n", cmd)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
package main
|
||||
|
||||
// pyjson.go — Python json.dumps-compatible serialization for the
|
||||
// tool-result `content` strings (json.dumps defaults: ", " / ": "
|
||||
// separators, ensure_ascii=True) plus an order-preserving JSON parser
|
||||
// so batteries can replay Python insertion-ordered objects verbatim.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf16"
|
||||
)
|
||||
|
||||
// PyDict is an insertion-ordered object (Python dict semantics).
|
||||
type PyDict struct {
|
||||
Keys []string
|
||||
Vals map[string]any
|
||||
}
|
||||
|
||||
func NewPyDict() *PyDict { return &PyDict{Vals: map[string]any{}} }
|
||||
|
||||
func (d *PyDict) Set(k string, v any) {
|
||||
if _, ok := d.Vals[k]; !ok {
|
||||
d.Keys = append(d.Keys, k)
|
||||
}
|
||||
d.Vals[k] = v
|
||||
}
|
||||
|
||||
func (d *PyDict) Get(k string) (any, bool) {
|
||||
v, ok := d.Vals[k]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// PyDump — json.dumps(obj) with Python's default separators and
|
||||
// ensure_ascii escaping. Maps serialize with sorted keys (Go map law);
|
||||
// use PyDict where Python insertion order must be reproduced.
|
||||
func PyDump(v any) string {
|
||||
s, _ := pyDumpTry(v)
|
||||
return s
|
||||
}
|
||||
|
||||
// pyDumpTry reports (ok=false) when the value has no Python-JSON
|
||||
// equivalent — the caller applies the oracle's degrade law.
|
||||
func pyDumpTry(v any) (string, bool) {
|
||||
var b strings.Builder
|
||||
if err := pyWrite(&b, v); err != nil {
|
||||
return "", false
|
||||
}
|
||||
return b.String(), true
|
||||
}
|
||||
|
||||
func pyWrite(b *strings.Builder, v any) error {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
b.WriteString("null")
|
||||
case bool:
|
||||
if x {
|
||||
b.WriteString("true")
|
||||
} else {
|
||||
b.WriteString("false")
|
||||
}
|
||||
case string:
|
||||
pyWriteString(b, x)
|
||||
case int:
|
||||
b.WriteString(strconv.Itoa(x))
|
||||
case int64:
|
||||
b.WriteString(strconv.FormatInt(x, 10))
|
||||
case float64:
|
||||
b.WriteString(pyFloat(x))
|
||||
case json.Number:
|
||||
b.WriteString(x.String())
|
||||
case *PyDict:
|
||||
b.WriteByte('{')
|
||||
for i, k := range x.Keys {
|
||||
if i > 0 {
|
||||
b.WriteString(", ")
|
||||
}
|
||||
pyWriteString(b, k)
|
||||
b.WriteString(": ")
|
||||
pyWrite(b, x.Vals[k])
|
||||
}
|
||||
b.WriteByte('}')
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(x))
|
||||
for k := range x {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sortStrings(keys)
|
||||
b.WriteByte('{')
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
b.WriteString(", ")
|
||||
}
|
||||
pyWriteString(b, k)
|
||||
b.WriteString(": ")
|
||||
pyWrite(b, x[k])
|
||||
}
|
||||
b.WriteByte('}')
|
||||
case []any:
|
||||
b.WriteByte('[')
|
||||
for i, e := range x {
|
||||
if i > 0 {
|
||||
b.WriteString(", ")
|
||||
}
|
||||
if err := pyWrite(b, e); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
b.WriteByte(']')
|
||||
case []map[string]any:
|
||||
b.WriteByte('[')
|
||||
for i, e := range x {
|
||||
if i > 0 {
|
||||
b.WriteString(", ")
|
||||
}
|
||||
if err := pyWrite(b, e); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
b.WriteByte(']')
|
||||
default:
|
||||
// Unknown Go types have no Python json equivalent; the caller
|
||||
// applies the oracle's degrade law.
|
||||
return fmt.Errorf("type not JSON-serializable: %T", v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const pyNonSerializable = "tool result not JSON serializable"
|
||||
|
||||
func pyFloat(f float64) string {
|
||||
if f == math.Trunc(f) && math.Abs(f) < 1e15 {
|
||||
return strconv.FormatFloat(f, 'f', 1, 64) // Python repr(2.0) → "2.0"
|
||||
}
|
||||
return strconv.FormatFloat(f, 'g', -1, 64)
|
||||
}
|
||||
|
||||
func pyWriteString(b *strings.Builder, s string) {
|
||||
b.WriteByte('"')
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '"':
|
||||
b.WriteString("\\\"")
|
||||
case '\\':
|
||||
b.WriteString("\\\\")
|
||||
case '\n':
|
||||
b.WriteString("\\n")
|
||||
case '\r':
|
||||
b.WriteString("\\r")
|
||||
case '\t':
|
||||
b.WriteString("\\t")
|
||||
case '\b':
|
||||
b.WriteString("\\b")
|
||||
case '\f':
|
||||
b.WriteString("\\f")
|
||||
default:
|
||||
if r < 0x20 || r > 0x7e {
|
||||
writeUnicodeEscape(b, r)
|
||||
} else {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
b.WriteByte('"')
|
||||
}
|
||||
|
||||
func writeUnicodeEscape(b *strings.Builder, r rune) {
|
||||
if r > 0xffff {
|
||||
hi, lo := utf16.EncodeRune(r)
|
||||
fmt.Fprintf(b, "\\u%04x\\u%04x", hi, lo)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(b, "\\u%04x", r)
|
||||
}
|
||||
|
||||
func sortStrings(s []string) {
|
||||
for i := 1; i < len(s); i++ {
|
||||
for j := i; j > 0 && s[j] < s[j-1]; j-- {
|
||||
s[j], s[j-1] = s[j-1], s[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ParsePyJSON — order-preserving JSON decode: objects → *PyDict,
|
||||
// arrays → []any, integral numbers → int, others → float64.
|
||||
func ParsePyJSON(data []byte) (any, error) {
|
||||
dec := json.NewDecoder(strings.NewReader(string(data)))
|
||||
dec.UseNumber()
|
||||
v, err := decodePyValue(dec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func decodePyValue(dec *json.Decoder) (any, error) {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decodePyToken(dec, tok)
|
||||
}
|
||||
|
||||
func decodePyToken(dec *json.Decoder, tok json.Token) (any, error) {
|
||||
switch t := tok.(type) {
|
||||
case json.Delim:
|
||||
switch t {
|
||||
case '{':
|
||||
d := NewPyDict()
|
||||
for dec.More() {
|
||||
keyTok, err := dec.Token()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, _ := keyTok.(string)
|
||||
val, err := decodePyValue(dec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.Set(key, val)
|
||||
}
|
||||
if _, err := dec.Token(); err != nil { // closing }
|
||||
return nil, err
|
||||
}
|
||||
return d, nil
|
||||
case '[':
|
||||
arr := []any{}
|
||||
for dec.More() {
|
||||
val, err := decodePyValue(dec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
arr = append(arr, val)
|
||||
}
|
||||
if _, err := dec.Token(); err != nil { // closing ]
|
||||
return nil, err
|
||||
}
|
||||
return arr, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected delim %v", t)
|
||||
case json.Number:
|
||||
if n, err := t.Int64(); err == nil && !strings.ContainsAny(t.String(), ".eE") {
|
||||
return int(n), nil
|
||||
}
|
||||
f, err := t.Float64()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return f, nil
|
||||
case string, bool, nil:
|
||||
return tok, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected token %v", tok)
|
||||
}
|
||||
@@ -0,0 +1,835 @@
|
||||
package main
|
||||
|
||||
// registry.go — factory-gap-pr: the provider_registry.py surface that
|
||||
// main.go's R2 W5b port left uncovered (dichotomy wave #2479 tranche).
|
||||
// Name-preserving parity port: every fn mirrors the Python oracle 1:1
|
||||
// (_load_config, _save_config, connect, disconnect, list_providers,
|
||||
// get_provider, list_models, _fetch_models, _fetch_ollama_models,
|
||||
// _fetch_openai_models, _fetch_anthropic_models, _clean_model_name,
|
||||
// to_dict, generate, _generate_ollama, _generate_openai,
|
||||
// _generate_anthropic, get_registry, connect_provider, list_all_models,
|
||||
// _ctx_budget). Network IO sits behind the injectable `Do` seam; the
|
||||
// tool executor behind the ToolExecutorFunc seam — tests never touch a
|
||||
// socket or the real executor.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ModelProvider — parity shape of the Python ModelProvider dataclass.
|
||||
type ModelProvider struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
ProviderType string `json:"provider_type"` // "ollama", "openai", "anthropic", "custom"
|
||||
APIKey string `json:"api_key,omitempty"`
|
||||
Models []map[string]any `json:"models"`
|
||||
Enabled bool `json:"enabled"`
|
||||
LastHealthCheck string `json:"last_health_check,omitempty"`
|
||||
HealthStatus string `json:"health_status"` // "healthy", "error", "unknown"
|
||||
}
|
||||
|
||||
func NewProvider(name, url, providerType, apiKey string) *ModelProvider {
|
||||
return &ModelProvider{
|
||||
Name: name, URL: url, ProviderType: providerType,
|
||||
APIKey: apiKey, Models: []map[string]any{},
|
||||
Enabled: true, HealthStatus: "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
// ProviderFromFields rebuilds a provider from a truth-row field map
|
||||
// (the frozen snapshot shape of the Python dataclass).
|
||||
func ProviderFromFields(f map[string]any) *ModelProvider {
|
||||
p := NewProvider(pyStr(f["name"]), pyStr(f["url"]), pyStr(f["provider_type"]), pyStr(f["api_key"]))
|
||||
p.Enabled = pyBoolOr(f["enabled"], true)
|
||||
p.LastHealthCheck = pyStr(f["last_health_check"])
|
||||
p.HealthStatus = pyStrOr(f["health_status"], "unknown")
|
||||
if models, ok := f["models"].([]any); ok {
|
||||
p.Models = []map[string]any{}
|
||||
for _, m := range models {
|
||||
if mm, ok := m.(map[string]any); ok {
|
||||
p.Models = append(p.Models, mm)
|
||||
}
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// ToDict — ModelProvider.to_dict (api_key and last_health_check are
|
||||
// deliberately excluded — the Python oracle's masking law).
|
||||
func (p *ModelProvider) ToDict() map[string]any {
|
||||
models := p.Models
|
||||
if models == nil {
|
||||
models = []map[string]any{}
|
||||
}
|
||||
return map[string]any{
|
||||
"name": p.Name, "url": p.URL, "provider_type": p.ProviderType,
|
||||
"models": models, "enabled": p.Enabled,
|
||||
"health_status": p.HealthStatus,
|
||||
}
|
||||
}
|
||||
|
||||
// ProviderRegistry — parity shape of the Python ProviderRegistry.
|
||||
type ProviderRegistry struct {
|
||||
mu sync.RWMutex
|
||||
Providers map[string]*ModelProvider
|
||||
ConfigPath string
|
||||
order []string // insertion order (Python dict semantics)
|
||||
// Do is the injectable HTTP seam: (url, body, headers, timeout) →
|
||||
// response body. nil = real net/http transport.
|
||||
Do func(url string, body []byte, headers map[string]string, timeout time.Duration) ([]byte, error)
|
||||
}
|
||||
|
||||
func NewRegistry(configPath string) *ProviderRegistry {
|
||||
reg := &ProviderRegistry{Providers: map[string]*ModelProvider{}, ConfigPath: configPath}
|
||||
reg.LoadConfig()
|
||||
return reg
|
||||
}
|
||||
|
||||
// defaultConfigPath — the Python oracle's ~/.config/rune/providers.json.
|
||||
// RUNE_REGISTRY_CONFIG overrides (tests); RUNE_MEMORY_DIR keeps the
|
||||
// legacy Go CLI location working.
|
||||
func defaultConfigPath() string {
|
||||
if p := os.Getenv("RUNE_REGISTRY_CONFIG"); p != "" {
|
||||
return p
|
||||
}
|
||||
if home := os.Getenv("RUNE_MEMORY_DIR"); home != "" {
|
||||
return filepath.Join(home, "provider_registry.json")
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "providers.json"
|
||||
}
|
||||
return filepath.Join(home, ".config", "rune", "providers.json")
|
||||
}
|
||||
|
||||
// providerFromConfigMap — the _load_config per-provider defaults law:
|
||||
// url="", type="custom", api_key=None, enabled=True.
|
||||
func providerFromConfigMap(name string, pc map[string]any) *ModelProvider {
|
||||
p := NewProvider(name, pyStrOr(pc["url"], ""), pyStrOr(pc["type"], "custom"), pyStr(pc["api_key"]))
|
||||
p.Enabled = pyBoolOr(pc["enabled"], true)
|
||||
return p
|
||||
}
|
||||
|
||||
// LoadConfig — ProviderRegistry._load_config. Missing file → empty
|
||||
// registry; malformed JSON → warning + empty (best-effort degrade).
|
||||
// Provider key order from the file is preserved (Python dict law).
|
||||
func (reg *ProviderRegistry) LoadConfig() {
|
||||
data, err := os.ReadFile(reg.ConfigPath)
|
||||
if err != nil {
|
||||
return // missing config is not an error — empty registry
|
||||
}
|
||||
keys, cfgs, err := decodeOrderedProviders(data)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Could not load provider config: %v\n", err)
|
||||
return
|
||||
}
|
||||
reg.mu.Lock()
|
||||
defer reg.mu.Unlock()
|
||||
reg.order = keys
|
||||
for _, name := range keys {
|
||||
reg.Providers[name] = providerFromConfigMap(name, cfgs[name])
|
||||
}
|
||||
}
|
||||
|
||||
// decodeOrderedProviders walks the JSON token stream so the providers'
|
||||
// file order survives (Go map decode would discard it).
|
||||
func decodeOrderedProviders(data []byte) ([]string, map[string]map[string]any, error) {
|
||||
cfgs := map[string]map[string]any{}
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if delim, ok := tok.(json.Delim); !ok || delim != '{' {
|
||||
return nil, nil, fmt.Errorf("config root is not an object")
|
||||
}
|
||||
var keys []string
|
||||
for dec.More() {
|
||||
keyTok, err := dec.Token()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
key, _ := keyTok.(string)
|
||||
if key == "providers" {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if delim, ok := tok.(json.Delim); !ok || delim != '{' {
|
||||
return nil, nil, fmt.Errorf("providers section is not an object")
|
||||
}
|
||||
for dec.More() {
|
||||
nameTok, err := dec.Token()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
name, _ := nameTok.(string)
|
||||
var pc map[string]any
|
||||
if err := dec.Decode(&pc); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if _, seen := cfgs[name]; !seen {
|
||||
keys = append(keys, name)
|
||||
}
|
||||
cfgs[name] = pc
|
||||
}
|
||||
if _, err := dec.Token(); err != nil { // closing }
|
||||
return nil, nil, err
|
||||
}
|
||||
} else {
|
||||
var skip any
|
||||
if err := dec.Decode(&skip); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys, cfgs, nil
|
||||
}
|
||||
|
||||
// SaveConfig — ProviderRegistry._save_config: API keys are masked with
|
||||
// "***" (never persisted in the clear), absent keys persist as null.
|
||||
func (reg *ProviderRegistry) SaveConfig() error {
|
||||
os.MkdirAll(filepath.Dir(reg.ConfigPath), 0o755)
|
||||
providers := map[string]any{}
|
||||
for name, p := range reg.Providers {
|
||||
var apiKey any
|
||||
if p.APIKey != "" {
|
||||
apiKey = "***"
|
||||
}
|
||||
providers[name] = map[string]any{
|
||||
"url": p.URL, "type": p.ProviderType,
|
||||
"api_key": apiKey, "enabled": p.Enabled,
|
||||
}
|
||||
}
|
||||
data, err := json.MarshalIndent(map[string]any{"providers": providers}, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(reg.ConfigPath, data, 0o600)
|
||||
}
|
||||
|
||||
// Connect — ProviderRegistry.connect (persists through SaveConfig).
|
||||
func (reg *ProviderRegistry) Connect(name, url, providerType, apiKey string) *ModelProvider {
|
||||
p := NewProvider(name, url, providerType, apiKey)
|
||||
reg.mu.Lock()
|
||||
if _, exists := reg.Providers[name]; !exists {
|
||||
reg.order = append(reg.order, name)
|
||||
}
|
||||
reg.Providers[name] = p
|
||||
reg.mu.Unlock()
|
||||
reg.SaveConfig()
|
||||
return p
|
||||
}
|
||||
|
||||
// Disconnect — ProviderRegistry.disconnect: absent name is a no-op.
|
||||
func (reg *ProviderRegistry) Disconnect(name string) {
|
||||
reg.mu.Lock()
|
||||
_, ok := reg.Providers[name]
|
||||
if ok {
|
||||
delete(reg.Providers, name)
|
||||
for i, n := range reg.order {
|
||||
if n == name {
|
||||
reg.order = append(reg.order[:i], reg.order[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
reg.mu.Unlock()
|
||||
if ok {
|
||||
reg.SaveConfig()
|
||||
}
|
||||
}
|
||||
|
||||
// orderedNames — the Python dict law: providers iterate in insertion
|
||||
// order (connect sequence, then config-file order on load).
|
||||
func (reg *ProviderRegistry) orderedNames() []string {
|
||||
reg.mu.RLock()
|
||||
defer reg.mu.RUnlock()
|
||||
names := make([]string, 0, len(reg.order))
|
||||
seen := map[string]bool{}
|
||||
for _, n := range reg.order {
|
||||
if _, ok := reg.Providers[n]; ok && !seen[n] {
|
||||
names = append(names, n)
|
||||
seen[n] = true
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// ListProviders — ProviderRegistry.list_providers.
|
||||
func (reg *ProviderRegistry) ListProviders() []map[string]any {
|
||||
out := []map[string]any{}
|
||||
for _, name := range reg.orderedNames() {
|
||||
out = append(out, reg.Providers[name].ToDict())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetProvider — ProviderRegistry.get_provider (nil when absent).
|
||||
func (reg *ProviderRegistry) GetProvider(name string) *ModelProvider {
|
||||
reg.mu.RLock()
|
||||
defer reg.mu.RUnlock()
|
||||
return reg.Providers[name]
|
||||
}
|
||||
|
||||
// ListModels — ProviderRegistry.list_models: enabled providers only,
|
||||
// per-provider fetch errors degrade to a warning (never crash), and
|
||||
// every model dict is annotated with provider + provider_type.
|
||||
func (reg *ProviderRegistry) ListModels() []map[string]any {
|
||||
all := []map[string]any{}
|
||||
for _, name := range reg.orderedNames() {
|
||||
p := reg.Providers[name]
|
||||
if !p.Enabled {
|
||||
continue
|
||||
}
|
||||
models, err := reg.FetchModels(p)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Could not fetch models from %s: %v\n", p.Name, err)
|
||||
continue
|
||||
}
|
||||
for _, m := range models {
|
||||
m["provider"] = p.Name
|
||||
m["provider_type"] = p.ProviderType
|
||||
all = append(all, m)
|
||||
}
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// FetchModels — ProviderRegistry._fetch_models dispatch.
|
||||
func (reg *ProviderRegistry) FetchModels(p *ModelProvider) ([]map[string]any, error) {
|
||||
switch p.ProviderType {
|
||||
case "ollama":
|
||||
return reg.FetchOllamaModels(p)
|
||||
case "openai":
|
||||
return reg.FetchOpenaiModels(p)
|
||||
case "anthropic":
|
||||
return reg.FetchAnthropicModels(p)
|
||||
default:
|
||||
return []map[string]any{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// FetchOllamaModels — ProviderRegistry._fetch_ollama_models. Every
|
||||
// failure (transport, decode, missing "name") is wrapped into the
|
||||
// oracle's single "Ollama connection failed: …" error.
|
||||
func (reg *ProviderRegistry) FetchOllamaModels(p *ModelProvider) ([]map[string]any, error) {
|
||||
body, err := reg.httpDo(p.URL+"/api/tags", nil, nil, 5*time.Second)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Ollama connection failed: %v", err)
|
||||
}
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return nil, fmt.Errorf("Ollama connection failed: %v", err)
|
||||
}
|
||||
models := []map[string]any{}
|
||||
rawModels, _ := data["models"].([]any)
|
||||
for _, rm := range rawModels {
|
||||
m, _ := rm.(map[string]any)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
nameVal, ok := m["name"]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("Ollama connection failed: %v", missingKeyErr("name"))
|
||||
}
|
||||
details, _ := m["details"].(map[string]any)
|
||||
if details == nil {
|
||||
details = map[string]any{}
|
||||
}
|
||||
models = append(models, map[string]any{
|
||||
"name": pyStr(nameVal),
|
||||
"display_name": CleanModelName(pyStr(nameVal)),
|
||||
"size_gb": roundHalfEven(pyFloatOr(m["size"], 0)/(1024*1024*1024), 1),
|
||||
"family": pyStrOr(details["family"], "unknown"),
|
||||
"parameters": pyStrOr(details["parameter_size"], "unknown"),
|
||||
})
|
||||
}
|
||||
return models, nil
|
||||
}
|
||||
|
||||
// FetchOpenaiModels — ProviderRegistry._fetch_openai_models.
|
||||
func (reg *ProviderRegistry) FetchOpenaiModels(p *ModelProvider) ([]map[string]any, error) {
|
||||
headers := map[string]string{"Content-Type": "application/json"}
|
||||
if p.APIKey != "" {
|
||||
headers["Authorization"] = "Bearer " + p.APIKey
|
||||
}
|
||||
body, err := reg.httpDo(p.URL+"/v1/models", nil, headers, 10*time.Second)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("[OI] API connection failed: %v", err)
|
||||
}
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return nil, fmt.Errorf("[OI] API connection failed: %v", err)
|
||||
}
|
||||
models := []map[string]any{}
|
||||
rawModels, _ := data["data"].([]any)
|
||||
for _, rm := range rawModels {
|
||||
m, _ := rm.(map[string]any)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
id := pyStrOr(m["id"], "unknown")
|
||||
models = append(models, map[string]any{
|
||||
"name": id, "display_name": id, "size_gb": 0,
|
||||
"family": "cloud", "parameters": "cloud",
|
||||
})
|
||||
}
|
||||
return models, nil
|
||||
}
|
||||
|
||||
// FetchAnthropicModels — ProviderRegistry._fetch_anthropic_models: the
|
||||
// Anthropic API has no /v1/models, so the oracle returns its pinned
|
||||
// static list (ported verbatim).
|
||||
func (reg *ProviderRegistry) FetchAnthropicModels(p *ModelProvider) ([]map[string]any, error) {
|
||||
return []map[string]any{
|
||||
{"name": "claude-sonnet-4-20250514", "display_name": "Claude Sonnet 4", "size_gb": 0, "family": "cloud", "parameters": "cloud"},
|
||||
{"name": "claude-3-5-sonnet-20241022", "display_name": "Claude 3.5 Sonnet", "size_gb": 0, "family": "cloud", "parameters": "cloud"},
|
||||
{"name": "claude-3-5-haiku-20241022", "display_name": "Claude 3.5 Haiku", "size_gb": 0, "family": "cloud", "parameters": "cloud"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
var (
|
||||
cleanReFamilySize = regexp.MustCompile(`(?i)([A-Za-z]+[\d.]*)-?(\d+[BMb])`)
|
||||
cleanReFamilyVers = regexp.MustCompile(`(?i)([A-Za-z]+)-(\d+)-(\d+[BMb])`)
|
||||
)
|
||||
|
||||
// CleanModelName — the _clean_model_name delegation target
|
||||
// (model_connect._clean_model_name), ported 1:1.
|
||||
func CleanModelName(name string) string {
|
||||
if strings.Contains(name, "/") && (strings.Contains(name, "hf.co") || strings.Contains(name, "huggingface")) {
|
||||
parts := strings.Split(name, "/")
|
||||
modelPart := name
|
||||
if len(parts) >= 3 {
|
||||
modelPart = parts[len(parts)-1]
|
||||
}
|
||||
if strings.Contains(modelPart, ":") {
|
||||
modelPart = strings.Split(modelPart, ":")[0]
|
||||
}
|
||||
if m := cleanReFamilySize.FindStringSubmatch(modelPart); m != nil {
|
||||
return m[1] + " " + strings.ToUpper(m[2])
|
||||
}
|
||||
if m := cleanReFamilyVers.FindStringSubmatch(modelPart); m != nil {
|
||||
return m[1] + " " + m[2] + " " + strings.ToUpper(m[3])
|
||||
}
|
||||
for _, sep := range []string{"-", "_", "."} {
|
||||
if strings.Contains(modelPart, sep) {
|
||||
firstPart := strings.Split(modelPart, sep)[0]
|
||||
if len(firstPart) > 3 {
|
||||
return firstPart
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(modelPart) > 30 {
|
||||
return modelPart[:30]
|
||||
}
|
||||
return modelPart
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// Generate — ProviderRegistry.generate: provider lookup (named or first
|
||||
// available), model autodetect via FetchModels, then the per-type
|
||||
// generate dispatch. Unsupported types yield an error dict.
|
||||
func (reg *ProviderRegistry) Generate(prompt string, providerName string, model string,
|
||||
maxTokens int, temperature float64, systemPrompt *string, tools []map[string]any) map[string]any {
|
||||
var p *ModelProvider
|
||||
if providerName != "" {
|
||||
p = reg.GetProvider(providerName)
|
||||
if p == nil {
|
||||
return map[string]any{"error": fmt.Sprintf("Provider not found: %s", providerName)}
|
||||
}
|
||||
} else {
|
||||
names := reg.orderedNames()
|
||||
if len(names) == 0 {
|
||||
return map[string]any{"error": "No providers connected"}
|
||||
}
|
||||
p = reg.Providers[names[0]]
|
||||
}
|
||||
if model == "" {
|
||||
models, err := reg.FetchModels(p)
|
||||
if err != nil {
|
||||
return map[string]any{"error": err.Error(), "model": model, "provider": p.Name}
|
||||
}
|
||||
if len(models) == 0 {
|
||||
return map[string]any{"error": fmt.Sprintf("No models available from %s", p.Name)}
|
||||
}
|
||||
model = pyStr(models[0]["name"])
|
||||
}
|
||||
switch p.ProviderType {
|
||||
case "ollama":
|
||||
return reg.GenerateOllama(p, prompt, model, maxTokens, temperature)
|
||||
case "openai":
|
||||
return reg.GenerateOpenai(p, prompt, model, maxTokens, temperature, systemPrompt, tools)
|
||||
case "anthropic":
|
||||
return reg.GenerateAnthropic(p, prompt, model, maxTokens, temperature, systemPrompt, tools)
|
||||
default:
|
||||
return map[string]any{"error": fmt.Sprintf("Unsupported provider type: %s", p.ProviderType)}
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateOllama — ProviderRegistry._generate_ollama. The num_ctx is
|
||||
// model-aware (_ctx_budget parity — never a hardcoded 8192).
|
||||
func (reg *ProviderRegistry) GenerateOllama(p *ModelProvider, prompt, model string,
|
||||
maxTokens int, temperature float64) map[string]any {
|
||||
start := time.Now()
|
||||
payload := map[string]any{
|
||||
"model": model, "prompt": prompt, "stream": false,
|
||||
"options": map[string]any{
|
||||
"temperature": temperature, "num_predict": maxTokens,
|
||||
"num_ctx": CtxBudget(model),
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
respBody, err := reg.httpDo(p.URL+"/api/generate", body,
|
||||
map[string]string{"Content-Type": "application/json"}, 120*time.Second)
|
||||
if err != nil {
|
||||
return map[string]any{"error": err.Error(), "model": model, "provider": p.Name}
|
||||
}
|
||||
var result map[string]any
|
||||
json.Unmarshal(respBody, &result)
|
||||
latency := int(time.Since(start).Milliseconds())
|
||||
return map[string]any{
|
||||
"response": strings.TrimSpace(pyStrOr(result["response"], "")),
|
||||
"model": model,
|
||||
"provider": p.Name,
|
||||
"latency_ms": latency,
|
||||
"tokens": pyIntOr(result["eval_count"], 0),
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateOpenai — ProviderRegistry._generate_openai with native tool
|
||||
// calling (tools → FormatToolsOpenai + tool_choice auto).
|
||||
func (reg *ProviderRegistry) GenerateOpenai(p *ModelProvider, prompt, model string,
|
||||
maxTokens int, temperature float64, systemPrompt *string, tools []map[string]any) map[string]any {
|
||||
start := time.Now()
|
||||
headers := map[string]string{"Content-Type": "application/json"}
|
||||
if p.APIKey != "" {
|
||||
headers["Authorization"] = "Bearer " + p.APIKey
|
||||
}
|
||||
messages := []map[string]any{}
|
||||
if systemPrompt != nil {
|
||||
messages = append(messages, map[string]any{"role": "system", "content": *systemPrompt})
|
||||
}
|
||||
messages = append(messages, map[string]any{"role": "user", "content": prompt})
|
||||
data := map[string]any{
|
||||
"model": model, "messages": messages,
|
||||
"max_tokens": maxTokens, "temperature": temperature,
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
data["tools"] = FormatToolsOpenai(tools)
|
||||
data["tool_choice"] = "auto"
|
||||
}
|
||||
body, _ := json.Marshal(data)
|
||||
respBody, err := reg.httpDo(p.URL+"/v1/chat/completions", body, headers, 120*time.Second)
|
||||
if err != nil {
|
||||
return map[string]any{"error": err.Error(), "model": model, "provider": p.Name}
|
||||
}
|
||||
var result map[string]any
|
||||
json.Unmarshal(respBody, &result)
|
||||
latency := int(time.Since(start).Milliseconds())
|
||||
|
||||
// choice = result.get("choices", [{}])[0] — an empty choices list
|
||||
// raises in the oracle and degrades to the error dict here too.
|
||||
message := map[string]any{}
|
||||
if choices, ok := result["choices"].([]any); ok {
|
||||
if len(choices) == 0 {
|
||||
return map[string]any{"error": "list index out of range", "model": model, "provider": p.Name}
|
||||
}
|
||||
if choice, ok := choices[0].(map[string]any); ok {
|
||||
if msg, ok := choice["message"].(map[string]any); ok {
|
||||
message = msg
|
||||
}
|
||||
}
|
||||
}
|
||||
toolCalls, _ := message["tool_calls"].([]any)
|
||||
usage, _ := result["usage"].(map[string]any)
|
||||
if usage == nil {
|
||||
usage = map[string]any{}
|
||||
}
|
||||
if len(toolCalls) > 0 {
|
||||
toolResults := ExecuteToolCalls(pyMaps(toolCalls))
|
||||
return map[string]any{
|
||||
"response": pyStrOr(message["content"], ""),
|
||||
"tool_calls": pyAny(toolCalls),
|
||||
"tool_results": toolResults,
|
||||
"model": model,
|
||||
"provider": p.Name,
|
||||
"latency_ms": latency,
|
||||
"tokens": pyIntOr(usage["total_tokens"], 0),
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"response": strings.TrimSpace(pyStrOr(message["content"], "")),
|
||||
"model": model,
|
||||
"provider": p.Name,
|
||||
"latency_ms": latency,
|
||||
"tokens": pyIntOr(usage["total_tokens"], 0),
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateAnthropic — ProviderRegistry._generate_anthropic with native
|
||||
// tool calling (tools → FormatToolsAnthropic; no tool_choice).
|
||||
func (reg *ProviderRegistry) GenerateAnthropic(p *ModelProvider, prompt, model string,
|
||||
maxTokens int, temperature float64, systemPrompt *string, tools []map[string]any) map[string]any {
|
||||
start := time.Now()
|
||||
headers := map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": p.APIKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
}
|
||||
data := map[string]any{
|
||||
"model": model,
|
||||
"max_tokens": maxTokens,
|
||||
"messages": []map[string]any{{"role": "user", "content": prompt}},
|
||||
}
|
||||
if systemPrompt != nil {
|
||||
data["system"] = *systemPrompt
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
data["tools"] = FormatToolsAnthropic(tools)
|
||||
}
|
||||
body, _ := json.Marshal(data)
|
||||
respBody, err := reg.httpDo(p.URL+"/v1/messages", body, headers, 120*time.Second)
|
||||
if err != nil {
|
||||
return map[string]any{"error": err.Error(), "model": model, "provider": p.Name}
|
||||
}
|
||||
var result map[string]any
|
||||
json.Unmarshal(respBody, &result)
|
||||
latency := int(time.Since(start).Milliseconds())
|
||||
|
||||
content, _ := result["content"].([]any)
|
||||
toolCalls := []any{}
|
||||
responseText := ""
|
||||
for _, blockAny := range content {
|
||||
block, _ := blockAny.(map[string]any)
|
||||
if block == nil {
|
||||
continue
|
||||
}
|
||||
switch pyStrOr(block["type"], "") {
|
||||
case "text":
|
||||
responseText += pyStrOr(block["text"], "")
|
||||
case "tool_use":
|
||||
toolCalls = append(toolCalls, block)
|
||||
}
|
||||
}
|
||||
usage, _ := result["usage"].(map[string]any)
|
||||
if usage == nil {
|
||||
usage = map[string]any{}
|
||||
}
|
||||
tokens := pyIntOr(usage["input_tokens"], 0) + pyIntOr(usage["output_tokens"], 0)
|
||||
if len(toolCalls) > 0 {
|
||||
toolResults := ExecuteToolCallsAnthropic(pyMaps(toolCalls))
|
||||
return map[string]any{
|
||||
"response": responseText,
|
||||
"tool_calls": toolCalls,
|
||||
"tool_results": toolResults,
|
||||
"model": model,
|
||||
"provider": p.Name,
|
||||
"latency_ms": latency,
|
||||
"tokens": tokens,
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"response": strings.TrimSpace(responseText),
|
||||
"model": model,
|
||||
"provider": p.Name,
|
||||
"latency_ms": latency,
|
||||
"tokens": tokens,
|
||||
}
|
||||
}
|
||||
|
||||
// httpDo — the HTTP seam. reg.Do overrides; otherwise the real
|
||||
// net/http transport (POST when body != nil, GET otherwise).
|
||||
func (reg *ProviderRegistry) httpDo(url string, body []byte, headers map[string]string,
|
||||
timeout time.Duration) ([]byte, error) {
|
||||
if reg.Do != nil {
|
||||
return reg.Do(url, body, headers, timeout)
|
||||
}
|
||||
client := &http.Client{Timeout: timeout}
|
||||
var req *http.Request
|
||||
var err error
|
||||
if body != nil {
|
||||
req, err = http.NewRequest("POST", url, bytes.NewReader(body))
|
||||
} else {
|
||||
req, err = http.NewRequest("GET", url, nil)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("HTTP Error %d: %s", resp.StatusCode, strings.TrimSpace(string(data)))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// ── global registry (get_registry / connect_provider / list_all_models) ──
|
||||
|
||||
var (
|
||||
globalRegistryMu sync.Mutex
|
||||
globalRegistry *ProviderRegistry
|
||||
)
|
||||
|
||||
// GetRegistry — module get_registry (thread-safe singleton).
|
||||
func GetRegistry() *ProviderRegistry {
|
||||
globalRegistryMu.Lock()
|
||||
defer globalRegistryMu.Unlock()
|
||||
if globalRegistry == nil {
|
||||
globalRegistry = NewRegistry(defaultConfigPath())
|
||||
}
|
||||
return globalRegistry
|
||||
}
|
||||
|
||||
// ConnectProvider — module connect_provider.
|
||||
func ConnectProvider(name, url, providerType, apiKey string) map[string]any {
|
||||
if providerType == "" {
|
||||
providerType = "openai"
|
||||
}
|
||||
p := GetRegistry().Connect(name, url, providerType, apiKey)
|
||||
return p.ToDict()
|
||||
}
|
||||
|
||||
// ListAllModels — module list_all_models.
|
||||
func ListAllModels() []map[string]any {
|
||||
return GetRegistry().ListModels()
|
||||
}
|
||||
|
||||
// Generate — module-level generate (delegates to the global registry).
|
||||
func Generate(prompt string, providerName string, model string, maxTokens int,
|
||||
temperature float64, systemPrompt *string, tools []map[string]any) map[string]any {
|
||||
return GetRegistry().Generate(prompt, providerName, model, maxTokens, temperature, systemPrompt, tools)
|
||||
}
|
||||
|
||||
// ── _ctx_budget (#28: model-aware num_ctx, never hardcoded 8192) ─────────
|
||||
|
||||
type budgetProfile struct {
|
||||
window int
|
||||
local bool
|
||||
}
|
||||
|
||||
// Mirrors model_registry.DEFAULT_PROFILES (id → context window, local).
|
||||
var budgetProfiles = map[string]budgetProfile{
|
||||
"deterministic": {0, true},
|
||||
"qwen2.5:3b": {32768, true},
|
||||
"qwen3:14b": {32768, true},
|
||||
"qwen3.6:27b": {32768, true},
|
||||
"mimo-v2.5-pro": {128000, false},
|
||||
}
|
||||
|
||||
const (
|
||||
outputReserve = 2048 // OUTPUT_RESERVE["default"]
|
||||
toolResultReSe = 1024 // TOOL_RESULT_RESERVE
|
||||
)
|
||||
|
||||
// CtxBudget — _ctx_budget: window − output_reserve − tool_reserve
|
||||
// (tool reserve only for non-local), floored at 1024. Unknown models
|
||||
// get the conservative 8192 window.
|
||||
func CtxBudget(modelID string) int {
|
||||
window := 8192
|
||||
local := true
|
||||
if p, ok := budgetProfiles[modelID]; ok {
|
||||
window = p.window
|
||||
if window == 0 {
|
||||
window = 8192
|
||||
}
|
||||
local = p.local
|
||||
}
|
||||
toolReserve := 0
|
||||
if !local {
|
||||
toolReserve = toolResultReSe
|
||||
}
|
||||
numCtx := window - outputReserve - toolReserve
|
||||
if numCtx < 1024 {
|
||||
numCtx = 1024
|
||||
}
|
||||
return numCtx
|
||||
}
|
||||
|
||||
// ── small parity helpers ─────────────────────────────────────────────────
|
||||
|
||||
func pyStr(v any) string {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func pyStrOr(v any, def string) string {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func pyBoolOr(v any, def bool) bool {
|
||||
if b, ok := v.(bool); ok {
|
||||
return b
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func pyFloatOr(v any, def float64) float64 {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n
|
||||
case int:
|
||||
return float64(n)
|
||||
default:
|
||||
return def
|
||||
}
|
||||
}
|
||||
|
||||
func pyIntOr(v any, def int) int {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n)
|
||||
case int:
|
||||
return n
|
||||
default:
|
||||
return def
|
||||
}
|
||||
}
|
||||
|
||||
func pyAny(v any) any { return v }
|
||||
|
||||
func pyMaps(items []any) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for _, it := range items {
|
||||
if m, ok := it.(map[string]any); ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// missingKeyErr — str(KeyError(k)) is quoted in Python.
|
||||
func missingKeyErr(k string) string { return "'" + k + "'" }
|
||||
|
||||
// roundHalfEven — Python round(): banker's rounding at the digit.
|
||||
func roundHalfEven(x float64, digits int) float64 {
|
||||
shift := math.Pow(10, float64(digits))
|
||||
return math.RoundToEven(x*shift) / shift
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package main
|
||||
|
||||
// toolcalls.go — _execute_tool_calls / _execute_tool_calls_anthropic
|
||||
// parity (factory-gap-pr). The tool executor is the IO seam: tests
|
||||
// inject ToolExecutorFunc; production wires the local tool runtime.
|
||||
// Shaping law (1:1 with the oracle): [OI] calls carry
|
||||
// function.name + function.arguments (a JSON string that degrades to {}
|
||||
// on decode failure); Anthropic calls carry name + input. Every result
|
||||
// is json.dumps'd — a non-serializable result degrades to the fixed
|
||||
// error marker (the oracle's PreparedTool crash fix), never aborts the
|
||||
// loop.
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// ToolExecutorFunc is the injectable executor seam: name + args →
|
||||
// result (any JSON-shape value). nil = no executor wired.
|
||||
var ToolExecutorFunc func(name string, args map[string]any) (any, error)
|
||||
|
||||
func runTool(name string, args map[string]any) any {
|
||||
if args == nil {
|
||||
args = map[string]any{}
|
||||
}
|
||||
if ToolExecutorFunc == nil {
|
||||
return map[string]any{"success": false, "error": "no tool executor wired"}
|
||||
}
|
||||
res, err := ToolExecutorFunc(name, args)
|
||||
if err != nil {
|
||||
return map[string]any{"success": false, "error": err.Error()}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// pyDumpResult — json.dumps(result) with the oracle's degrade law:
|
||||
// a result with no JSON-shape equivalent becomes the fixed error marker
|
||||
// (insertion-ordered to match the Python fallback's byte shape).
|
||||
func pyDumpResult(result any) string {
|
||||
if s, ok := pyDumpTry(result); ok {
|
||||
return s
|
||||
}
|
||||
d := NewPyDict()
|
||||
d.Set("success", false)
|
||||
d.Set("error", pyNonSerializable)
|
||||
return PyDump(d)
|
||||
}
|
||||
|
||||
// ExecuteToolCalls — _execute_tool_calls.
|
||||
func ExecuteToolCalls(toolCalls []map[string]any) []map[string]any {
|
||||
results := []map[string]any{}
|
||||
for _, call := range toolCalls {
|
||||
fn, _ := call["function"].(map[string]any)
|
||||
if fn == nil {
|
||||
fn = map[string]any{}
|
||||
}
|
||||
name := pyStrOr(fn["name"], "")
|
||||
var args map[string]any
|
||||
switch a := fn["arguments"].(type) {
|
||||
case string:
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(a), &parsed); err == nil {
|
||||
if m, ok := parsed.(map[string]any); ok {
|
||||
args = m
|
||||
} else {
|
||||
args = map[string]any{}
|
||||
}
|
||||
} else {
|
||||
args = map[string]any{}
|
||||
}
|
||||
case map[string]any:
|
||||
args = a
|
||||
default:
|
||||
args = map[string]any{}
|
||||
}
|
||||
results = append(results, map[string]any{
|
||||
"tool_call_id": pyStrOr(call["id"], ""),
|
||||
"role": "tool",
|
||||
"content": pyDumpResult(runTool(name, args)),
|
||||
})
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// ExecuteToolCallsAnthropic — _execute_tool_calls_anthropic.
|
||||
func ExecuteToolCallsAnthropic(toolCalls []map[string]any) []map[string]any {
|
||||
results := []map[string]any{}
|
||||
for _, call := range toolCalls {
|
||||
name := pyStrOr(call["name"], "")
|
||||
args, _ := call["input"].(map[string]any)
|
||||
results = append(results, map[string]any{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": pyStrOr(call["id"], ""),
|
||||
"content": pyDumpResult(runTool(name, args)),
|
||||
})
|
||||
}
|
||||
return results
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
[
|
||||
{
|
||||
"fn": "_format_tools_openai",
|
||||
"args": [
|
||||
[]
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": []
|
||||
},
|
||||
{
|
||||
"fn": "_format_tools_openai",
|
||||
"args": [
|
||||
[
|
||||
{
|
||||
"name": "read_file",
|
||||
"description": "Read a file",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"description": "Read a file",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"fn": "_format_tools_openai",
|
||||
"args": [
|
||||
[
|
||||
{
|
||||
"name": "write_file"
|
||||
}
|
||||
]
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "write_file",
|
||||
"description": "",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"fn": "_format_tools_openai",
|
||||
"args": [
|
||||
[
|
||||
{
|
||||
"name": "read_file",
|
||||
"description": "Read a file",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "write_file"
|
||||
}
|
||||
]
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"description": "Read a file",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "write_file",
|
||||
"description": "",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"fn": "_format_tools_anthropic",
|
||||
"args": [
|
||||
[]
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": []
|
||||
},
|
||||
{
|
||||
"fn": "_format_tools_anthropic",
|
||||
"args": [
|
||||
[
|
||||
{
|
||||
"name": "read_file",
|
||||
"description": "Read a file",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": [
|
||||
{
|
||||
"name": "read_file",
|
||||
"description": "Read a file",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"fn": "_format_tools_anthropic",
|
||||
"args": [
|
||||
[
|
||||
{
|
||||
"name": "write_file"
|
||||
}
|
||||
]
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": [
|
||||
{
|
||||
"name": "write_file",
|
||||
"description": "",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"fn": "_format_tools_anthropic",
|
||||
"args": [
|
||||
[
|
||||
{
|
||||
"name": "read_file",
|
||||
"description": "Read a file",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "write_file"
|
||||
}
|
||||
]
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": [
|
||||
{
|
||||
"name": "read_file",
|
||||
"description": "Read a file",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "write_file",
|
||||
"description": "",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"fn": "_ctx_budget",
|
||||
"args": [
|
||||
"unknown-model-xyz"
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": 6144
|
||||
},
|
||||
{
|
||||
"fn": "_ctx_budget",
|
||||
"args": [
|
||||
"deterministic"
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": 6144
|
||||
},
|
||||
{
|
||||
"fn": "_ctx_budget",
|
||||
"args": [
|
||||
"qwen2.5:3b"
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": 30720
|
||||
},
|
||||
{
|
||||
"fn": "_ctx_budget",
|
||||
"args": [
|
||||
"qwen3:14b"
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": 30720
|
||||
},
|
||||
{
|
||||
"fn": "_ctx_budget",
|
||||
"args": [
|
||||
"qwen3.6:27b"
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": 30720
|
||||
},
|
||||
{
|
||||
"fn": "_ctx_budget",
|
||||
"args": [
|
||||
"mimo-v2.5-pro"
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": 124928
|
||||
},
|
||||
{
|
||||
"fn": "_execute_tool_calls",
|
||||
"args": [
|
||||
[
|
||||
{
|
||||
"id": "call_1",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"arguments": "{\"path\": \"/nonexistent/zzz.txt\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": [
|
||||
{
|
||||
"tool_call_id": "call_1",
|
||||
"role": "tool",
|
||||
"content": "{\"success\": false, \"error\": \"Access denied: /nonexistent/zzz.txt is outside allowed directories\"}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"fn": "_execute_tool_calls",
|
||||
"args": [
|
||||
[
|
||||
{
|
||||
"id": "call_2",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"arguments": "not-json"
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": [
|
||||
{
|
||||
"tool_call_id": "call_2",
|
||||
"role": "tool",
|
||||
"content": "{\"success\": false, \"error\": \"Not a file: \"}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"fn": "_execute_tool_calls",
|
||||
"args": [
|
||||
[
|
||||
{
|
||||
"id": "call_3",
|
||||
"function": {
|
||||
"name": "no_such_tool_xyz",
|
||||
"arguments": "{}"
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": [
|
||||
{
|
||||
"tool_call_id": "call_3",
|
||||
"role": "tool",
|
||||
"content": "{\"success\": false, \"error\": \"tool result not JSON serializable\"}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"fn": "_execute_tool_calls_anthropic",
|
||||
"args": [
|
||||
[
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "tu_1",
|
||||
"name": "read_file",
|
||||
"input": {
|
||||
"path": "/nonexistent/zzz.txt"
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "tu_1",
|
||||
"content": "{\"success\": false, \"error\": \"Access denied: /nonexistent/zzz.txt is outside allowed directories\"}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"fn": "_execute_tool_calls_anthropic",
|
||||
"args": [
|
||||
[
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "tu_2",
|
||||
"name": "no_such_tool_xyz",
|
||||
"input": {}
|
||||
}
|
||||
]
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "tu_2",
|
||||
"content": "{\"success\": false, \"error\": \"tool result not JSON serializable\"}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -31,7 +31,7 @@ func TestFormatParity(t *testing.T) {
|
||||
for _, c := range doc.Cases {
|
||||
var got []map[string]interface{}
|
||||
if c.Fn == "openai" {
|
||||
got = FormatToolsOpenAI(tools)
|
||||
got = FormatToolsOpenai(tools)
|
||||
} else {
|
||||
got = FormatToolsAnthropic(tools)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,359 @@
|
||||
package main
|
||||
|
||||
// truth_registry_test.go — factory-gap-pr parity gate for the
|
||||
// ProviderRegistry method surface: replays truth_registry.json (frozen
|
||||
// from the live Python oracle with urllib fully scripted by
|
||||
// scripts/gen_provider_registry_truth.py) against the Go port. HTTP
|
||||
// goes through the injected Do seam; config files live in per-test
|
||||
// temp dirs. latency_ms is excluded on both sides (the oracle's clock
|
||||
// is not injectable — no-clock law).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func dget(d *PyDict, k string) any {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
v, _ := d.Get(k)
|
||||
return v
|
||||
}
|
||||
|
||||
type capturedReq struct {
|
||||
url string
|
||||
body []byte
|
||||
}
|
||||
|
||||
// buildRegistryFromInput constructs a registry from a truth row:
|
||||
// providers inserted in frozen key order (ParsePyJSON preserves the
|
||||
// Python dict order), HTTP routed through the scripted Do seam.
|
||||
func buildRegistryFromInput(t *testing.T, input *PyDict) (*ProviderRegistry, *[]capturedReq) {
|
||||
t.Helper()
|
||||
reg := NewRegistry(filepath.Join(t.TempDir(), "providers.json"))
|
||||
captured := &[]capturedReq{}
|
||||
if routesAny := dget(input, "routes"); routesAny != nil {
|
||||
routes, _ := routesAny.([]any)
|
||||
reg.Do = func(url string, body []byte, headers map[string]string, timeout time.Duration) ([]byte, error) {
|
||||
for _, r := range routes {
|
||||
route, _ := r.(*PyDict)
|
||||
suffix, _ := dget(route, "suffix").(string)
|
||||
if suffix != "" && len(url) >= len(suffix) && url[len(url)-len(suffix):] == suffix {
|
||||
if exc, ok := dget(route, "exc").(string); ok {
|
||||
return nil, &urlError{msg: exc}
|
||||
}
|
||||
*captured = append(*captured, capturedReq{url: url, body: body})
|
||||
return json.Marshal(plainJSON(dget(route, "body")))
|
||||
}
|
||||
}
|
||||
return nil, &urlError{msg: "unexpected url: " + url}
|
||||
}
|
||||
}
|
||||
if provsAny := dget(input, "providers"); provsAny != nil {
|
||||
provs, _ := provsAny.(*PyDict)
|
||||
if provs != nil {
|
||||
for _, name := range provs.Keys {
|
||||
fields, _ := provs.Vals[name].(*PyDict)
|
||||
reg.Providers[name] = ProviderFromFields(plainJSON(fields).(map[string]any))
|
||||
reg.order = append(reg.order, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return reg, captured
|
||||
}
|
||||
|
||||
// urlError mirrors urllib.error.URLError's str(): "<urlopen error msg>".
|
||||
type urlError struct{ msg string }
|
||||
|
||||
func (e *urlError) Error() string { return "<urlopen error " + e.msg + ">" }
|
||||
|
||||
func providerFieldsMap(p *ModelProvider) map[string]any {
|
||||
var apiKey any
|
||||
if p.APIKey != "" {
|
||||
apiKey = p.APIKey
|
||||
}
|
||||
var lhc any
|
||||
if p.LastHealthCheck != "" {
|
||||
lhc = p.LastHealthCheck
|
||||
}
|
||||
return map[string]any{
|
||||
"name": p.Name, "url": p.URL, "provider_type": p.ProviderType,
|
||||
"api_key": apiKey, "enabled": p.Enabled,
|
||||
"last_health_check": lhc, "health_status": p.HealthStatus,
|
||||
"models": p.Models,
|
||||
}
|
||||
}
|
||||
|
||||
func providersState(reg *ProviderRegistry) map[string]any {
|
||||
out := map[string]any{}
|
||||
for name, p := range reg.Providers {
|
||||
out[name] = providerFieldsMap(p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func readConfigIfPresent(t *testing.T, path string) (any, bool) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
var cfg any
|
||||
json.Unmarshal(data, &cfg)
|
||||
return cfg, true
|
||||
}
|
||||
|
||||
// unwrapExpected handles both frozen shapes: top-level expected_error
|
||||
// (row()) and the nested {"expected_request", "expected"/"expected_error"}
|
||||
// envelope (gen_case()).
|
||||
func unwrapExpected(c *PyDict) (result, request any, errText string) {
|
||||
if s, ok := dget(c, "expected_error").(string); ok && s != "" {
|
||||
return nil, nil, s
|
||||
}
|
||||
exp := dget(c, "expected")
|
||||
if em, ok := exp.(*PyDict); ok {
|
||||
if _, isEnvelope := em.Get("expected_request"); isEnvelope {
|
||||
if s, ok := dget(em, "expected_error").(string); ok {
|
||||
return nil, dget(em, "expected_request"), s
|
||||
}
|
||||
return dget(em, "expected"), dget(em, "expected_request"), ""
|
||||
}
|
||||
}
|
||||
return exp, nil, ""
|
||||
}
|
||||
|
||||
func genArgs(input *PyDict) (prompt, model string, maxTokens int, temperature float64, systemPrompt *string, tools []map[string]any) {
|
||||
prompt, _ = dget(input, "prompt").(string)
|
||||
model, _ = dget(input, "model").(string)
|
||||
maxTokens = pyIntOr(dget(input, "max_tokens"), 500)
|
||||
temperature = pyFloatOr(dget(input, "temperature"), 0.7)
|
||||
if sp, ok := dget(input, "system_prompt").(string); ok {
|
||||
systemPrompt = &sp
|
||||
}
|
||||
if toolsAny, ok := dget(input, "tools").([]any); ok {
|
||||
tools = pyMaps(plainJSON(toolsAny).([]any))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func stripLatency(m map[string]any) map[string]any {
|
||||
out := map[string]any{}
|
||||
for k, v := range m {
|
||||
if k != "latency_ms" {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestRegistryTruthParity(t *testing.T) {
|
||||
raw, err := ParsePyJSON(mustRead(t, "truth_registry.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("truth_registry.json: %v", err)
|
||||
}
|
||||
cases, _ := raw.([]any)
|
||||
for _, caseAny := range cases {
|
||||
c, _ := caseAny.(*PyDict)
|
||||
fn, _ := dget(c, "fn").(string)
|
||||
name, _ := dget(c, "name").(string)
|
||||
input, _ := dget(c, "input").(*PyDict)
|
||||
t.Run(fn+"/"+name, func(t *testing.T) {
|
||||
runRegistryCase(t, fn, name, input, c)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mustRead(t *testing.T, name string) []byte {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(name)
|
||||
if err != nil {
|
||||
t.Fatalf("%s missing: %v", name, err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// scriptExecutorFromExpected installs a ToolExecutorFunc whose scripted
|
||||
// results are the Python tool_executor outputs parsed back out of the
|
||||
// frozen tool_results content strings. Returns a restore func.
|
||||
func scriptExecutorFromExpected(t *testing.T, result any) func() {
|
||||
t.Helper()
|
||||
var scripted []any
|
||||
if resMap, ok := plainJSON(result).(map[string]any); ok {
|
||||
if trs, ok := resMap["tool_results"].([]any); ok {
|
||||
for _, tr := range trs {
|
||||
trm, _ := tr.(map[string]any)
|
||||
content, _ := trm["content"].(string)
|
||||
parsed, err := ParsePyJSON([]byte(content))
|
||||
if err != nil {
|
||||
t.Fatalf("tool result content parse: %v", err)
|
||||
}
|
||||
scripted = append(scripted, parsed)
|
||||
}
|
||||
}
|
||||
}
|
||||
oldExec := ToolExecutorFunc
|
||||
ToolExecutorFunc = func(name string, args map[string]any) (any, error) {
|
||||
if len(scripted) == 0 {
|
||||
t.Errorf("unexpected extra tool call %q", name)
|
||||
return nil, nil
|
||||
}
|
||||
r := scripted[0]
|
||||
scripted = scripted[1:]
|
||||
return r, nil
|
||||
}
|
||||
return func() { ToolExecutorFunc = oldExec }
|
||||
}
|
||||
|
||||
func runRegistryCase(t *testing.T, fn, name string, input *PyDict, c *PyDict) {
|
||||
t.Helper()
|
||||
result, request, errText := unwrapExpected(c)
|
||||
|
||||
switch fn {
|
||||
case "to_dict":
|
||||
fields, _ := plainJSON(dget(input, "provider")).(map[string]any)
|
||||
p := ProviderFromFields(fields)
|
||||
compareJSON(t, name, p.ToDict(), result)
|
||||
|
||||
case "_load_config":
|
||||
path := filepath.Join(t.TempDir(), "providers.json")
|
||||
if content := dget(input, "content"); content != nil {
|
||||
b, _ := json.Marshal(plainJSON(content))
|
||||
os.WriteFile(path, b, 0o600)
|
||||
}
|
||||
reg := NewRegistry(path)
|
||||
compareJSON(t, name, map[string]any{"providers": providersState(reg)}, result)
|
||||
|
||||
case "_save_config":
|
||||
reg, _ := buildRegistryFromInput(t, input)
|
||||
reg.SaveConfig()
|
||||
cfg, ok := readConfigIfPresent(t, reg.ConfigPath)
|
||||
if !ok {
|
||||
t.Fatalf("%s: config file not written", name)
|
||||
}
|
||||
compareJSON(t, name, map[string]any{"written": cfg}, result)
|
||||
|
||||
case "connect":
|
||||
reg := NewRegistry(filepath.Join(t.TempDir(), "providers.json"))
|
||||
p := reg.Connect(dget(input, "name").(string), dget(input, "url").(string),
|
||||
dget(input, "provider_type").(string), dget(input, "api_key").(string))
|
||||
got := map[string]any{"provider": p.ToDict()}
|
||||
if cfg, ok := readConfigIfPresent(t, reg.ConfigPath); ok {
|
||||
got["config"] = cfg
|
||||
}
|
||||
compareJSON(t, name, got, result)
|
||||
|
||||
case "list_providers":
|
||||
reg, _ := buildRegistryFromInput(t, input)
|
||||
compareJSON(t, name, map[string]any{"providers": reg.ListProviders()}, result)
|
||||
|
||||
case "get_provider":
|
||||
reg, _ := buildRegistryFromInput(t, input)
|
||||
p := reg.GetProvider(dget(input, "name").(string))
|
||||
var pv any
|
||||
if p != nil {
|
||||
pv = p.ToDict()
|
||||
}
|
||||
compareJSON(t, name, map[string]any{"provider": pv}, result)
|
||||
|
||||
case "disconnect":
|
||||
reg, _ := buildRegistryFromInput(t, input)
|
||||
if seed := dget(input, "seed_config"); seed != nil {
|
||||
b, _ := json.Marshal(plainJSON(seed))
|
||||
os.WriteFile(reg.ConfigPath, b, 0o600)
|
||||
}
|
||||
reg.Disconnect(dget(input, "name").(string))
|
||||
got := map[string]any{"providers": providersState(reg)}
|
||||
if _, wantConfig := dget(c, "expected").(*PyDict).Get("config"); wantConfig {
|
||||
if cfg, ok := readConfigIfPresent(t, reg.ConfigPath); ok {
|
||||
got["config"] = cfg
|
||||
}
|
||||
}
|
||||
compareJSON(t, name, got, result)
|
||||
|
||||
case "_clean_model_name":
|
||||
cn, _ := dget(input, "name").(string)
|
||||
compareJSON(t, name, CleanModelName(cn), result)
|
||||
|
||||
case "_fetch_ollama_models", "_fetch_openai_models", "_fetch_anthropic_models", "_fetch_models":
|
||||
reg, _ := buildRegistryFromInput(t, input)
|
||||
fields, _ := plainJSON(dget(input, "provider")).(map[string]any)
|
||||
p := ProviderFromFields(fields)
|
||||
got, err := reg.FetchModels(p)
|
||||
switch fn {
|
||||
case "_fetch_ollama_models":
|
||||
got, err = reg.FetchOllamaModels(p)
|
||||
case "_fetch_openai_models":
|
||||
got, err = reg.FetchOpenaiModels(p)
|
||||
case "_fetch_anthropic_models":
|
||||
got, err = reg.FetchAnthropicModels(p)
|
||||
}
|
||||
if errText != "" {
|
||||
if err == nil {
|
||||
t.Errorf("%s: want error %q, got none", name, errText)
|
||||
} else if err.Error() != errText {
|
||||
t.Errorf("%s: error:\n got %q\nwant %q", name, err.Error(), errText)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("%s: unexpected error: %v", name, err)
|
||||
}
|
||||
compareJSON(t, name, got, result)
|
||||
|
||||
case "list_models":
|
||||
reg, _ := buildRegistryFromInput(t, input)
|
||||
compareJSON(t, name, reg.ListModels(), result)
|
||||
|
||||
case "generate":
|
||||
reg, _ := buildRegistryFromInput(t, input)
|
||||
prompt, _ := dget(input, "prompt").(string)
|
||||
providerName, _ := dget(input, "provider_name").(string)
|
||||
model, _ := dget(input, "model").(string)
|
||||
got := reg.Generate(prompt, providerName, model, 500, 0.7, nil, nil)
|
||||
compareJSON(t, name, got, result)
|
||||
|
||||
case "_generate_ollama", "_generate_openai", "_generate_anthropic":
|
||||
reg, captured := buildRegistryFromInput(t, input)
|
||||
fields, _ := plainJSON(dget(input, "provider")).(map[string]any)
|
||||
p := ProviderFromFields(fields)
|
||||
prompt, model, maxTokens, temperature, systemPrompt, tools := genArgs(input)
|
||||
if result != nil {
|
||||
defer scriptExecutorFromExpected(t, result)()
|
||||
}
|
||||
var got map[string]any
|
||||
switch fn {
|
||||
case "_generate_ollama":
|
||||
got = reg.GenerateOllama(p, prompt, model, maxTokens, temperature)
|
||||
case "_generate_openai":
|
||||
got = reg.GenerateOpenai(p, prompt, model, maxTokens, temperature, systemPrompt, tools)
|
||||
case "_generate_anthropic":
|
||||
got = reg.GenerateAnthropic(p, prompt, model, maxTokens, temperature, systemPrompt, tools)
|
||||
}
|
||||
if request == nil {
|
||||
if len(*captured) != 0 {
|
||||
t.Errorf("%s: expected no HTTP call, captured %d", name, len(*captured))
|
||||
}
|
||||
} else {
|
||||
if len(*captured) == 0 {
|
||||
t.Fatalf("%s: expected an HTTP call, captured none", name)
|
||||
}
|
||||
var gotReq any
|
||||
json.Unmarshal((*captured)[0].body, &gotReq)
|
||||
compareJSON(t, name+"/request", gotReq, request)
|
||||
}
|
||||
if errText != "" {
|
||||
if gotErr, ok := got["error"].(string); !ok || gotErr != errText {
|
||||
t.Errorf("%s: error:\n got %v\nwant %q", name, got["error"], errText)
|
||||
}
|
||||
return
|
||||
}
|
||||
compareJSON(t, name, stripLatency(got), result)
|
||||
|
||||
default:
|
||||
t.Errorf("unhandled frozen fn %s", fn)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package main
|
||||
|
||||
// truth_test.go — factory-gap-pr parity gate: replays the frozen
|
||||
// oracle truth (truth.json, generated by scripts/
|
||||
// gen_provider_registry_truth.py via the shared freeze_truth harness)
|
||||
// VERBATIM against the Go port. The tool executor is the injected seam
|
||||
// — its scripted results are the Python tool_executor outputs parsed
|
||||
// back out of the frozen content strings, so the battery never touches
|
||||
// the real executor and never hits a socket.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func loadTruthFile(t *testing.T, name string, dst any) {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(name)
|
||||
if err != nil {
|
||||
t.Fatalf("%s missing: %v", name, err)
|
||||
}
|
||||
if err := json.Unmarshal(b, dst); err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func compareJSON(t *testing.T, name string, got, want any) {
|
||||
t.Helper()
|
||||
gb, _ := json.Marshal(got)
|
||||
wb, _ := json.Marshal(plainJSON(want))
|
||||
if string(gb) != string(wb) {
|
||||
t.Errorf("%s:\n got %q\nwant %q", name, gb, wb)
|
||||
}
|
||||
}
|
||||
|
||||
// plainJSON converts PyDict (order-preserving decode) back to plain
|
||||
// maps for order-insensitive comparison.
|
||||
func plainJSON(v any) any {
|
||||
switch x := v.(type) {
|
||||
case *PyDict:
|
||||
m := map[string]any{}
|
||||
for _, k := range x.Keys {
|
||||
m[k] = plainJSON(x.Vals[k])
|
||||
}
|
||||
return m
|
||||
case []any:
|
||||
out := make([]any, len(x))
|
||||
for i, e := range x {
|
||||
out[i] = plainJSON(e)
|
||||
}
|
||||
return out
|
||||
case []map[string]any:
|
||||
out := make([]any, len(x))
|
||||
for i, e := range x {
|
||||
out[i] = plainJSON(e)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func TestHarnessTruthParity(t *testing.T) {
|
||||
var cases []struct {
|
||||
Fn string `json:"fn"`
|
||||
Args []any `json:"args"`
|
||||
Kwargs map[string]any `json:"kwargs"`
|
||||
Expected any `json:"expected"`
|
||||
}
|
||||
loadTruthFile(t, "truth.json", &cases)
|
||||
for _, c := range cases {
|
||||
switch c.Fn {
|
||||
case "_format_tools_openai":
|
||||
compareJSON(t, c.Fn, FormatToolsOpenai(argsTools(c.Args)), c.Expected)
|
||||
case "_format_tools_anthropic":
|
||||
compareJSON(t, c.Fn, FormatToolsAnthropic(argsTools(c.Args)), c.Expected)
|
||||
case "_ctx_budget":
|
||||
modelID, _ := c.Args[0].(string)
|
||||
compareJSON(t, c.Fn, CtxBudget(modelID), c.Expected)
|
||||
case "_execute_tool_calls", "_execute_tool_calls_anthropic":
|
||||
expList, _ := c.Expected.([]any)
|
||||
scripted := make([]any, len(expList))
|
||||
for i, e := range expList {
|
||||
em, _ := e.(map[string]any)
|
||||
content, _ := em["content"].(string)
|
||||
parsed, err := ParsePyJSON([]byte(content))
|
||||
if err != nil {
|
||||
t.Fatalf("%s case %d: content parse: %v", c.Fn, i, err)
|
||||
}
|
||||
scripted[i] = parsed
|
||||
}
|
||||
idx := 0
|
||||
oldExec := ToolExecutorFunc
|
||||
ToolExecutorFunc = func(name string, args map[string]any) (any, error) {
|
||||
if idx >= len(scripted) {
|
||||
t.Errorf("%s: unexpected extra tool call %q", c.Fn, name)
|
||||
return nil, nil
|
||||
}
|
||||
r := scripted[idx]
|
||||
idx++
|
||||
return r, nil
|
||||
}
|
||||
var got []map[string]any
|
||||
if c.Fn == "_execute_tool_calls_anthropic" {
|
||||
got = ExecuteToolCallsAnthropic(argsTools(c.Args))
|
||||
} else {
|
||||
got = ExecuteToolCalls(argsTools(c.Args))
|
||||
}
|
||||
ToolExecutorFunc = oldExec
|
||||
if idx != len(scripted) {
|
||||
t.Errorf("%s: executor invoked %d times, want %d", c.Fn, idx, len(scripted))
|
||||
}
|
||||
compareJSON(t, c.Fn, got, c.Expected)
|
||||
default:
|
||||
t.Errorf("unhandled frozen fn %s", c.Fn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// argsTools unwraps the single tool-list argument of the frozen
|
||||
// _format_tools_* / _execute_tool_calls* cases.
|
||||
func argsTools(args []any) []map[string]any {
|
||||
if len(args) == 0 {
|
||||
return nil
|
||||
}
|
||||
tools, _ := args[0].([]any)
|
||||
return pyMaps(tools)
|
||||
}
|
||||
|
||||
// TestExecuteToolCallsNonSerializableFallback — the Go-side branch of
|
||||
// the oracle's PreparedTool degrade law: a tool result with no
|
||||
// JSON-shape equivalent becomes the fixed error marker.
|
||||
func TestExecuteToolCallsNonSerializableFallback(t *testing.T) {
|
||||
oldExec := ToolExecutorFunc
|
||||
defer func() { ToolExecutorFunc = oldExec }()
|
||||
ToolExecutorFunc = func(name string, args map[string]any) (any, error) {
|
||||
return make(chan int), nil // never JSON-serializable
|
||||
}
|
||||
got := ExecuteToolCalls([]map[string]any{{
|
||||
"id": "c1",
|
||||
"function": map[string]any{"name": "x", "arguments": "{}"},
|
||||
}})
|
||||
want := `{"success": false, "error": "tool result not JSON serializable"}`
|
||||
if got[0]["content"] != want {
|
||||
t.Errorf("content:\n got %v\nwant %s", got[0]["content"], want)
|
||||
}
|
||||
if got[0]["tool_call_id"] != "c1" || got[0]["role"] != "tool" {
|
||||
t.Errorf("wrapper shape wrong: %v", got[0])
|
||||
}
|
||||
}
|
||||
@@ -92,11 +92,15 @@ def _execute_tool_calls(tool_calls: list) -> list:
|
||||
args = {}
|
||||
|
||||
result = tool_executor.execute_tool(name, args)
|
||||
try:
|
||||
content = json.dumps(result)
|
||||
except (TypeError, ValueError): # noqa: BLE001 — non-JSON tool results (e.g. PreparedTool) degrade to an error marker, never crash the loop
|
||||
content = json.dumps({"success": False, "error": "tool result not JSON serializable"})
|
||||
results.append(
|
||||
{
|
||||
"tool_call_id": call.get("id", ""),
|
||||
"role": "tool",
|
||||
"content": json.dumps(result),
|
||||
"content": content,
|
||||
}
|
||||
)
|
||||
return results
|
||||
@@ -115,11 +119,15 @@ def _execute_tool_calls_anthropic(tool_calls: list) -> list:
|
||||
args = call.get("input", {})
|
||||
|
||||
result = tool_executor.execute_tool(name, args)
|
||||
try:
|
||||
content = json.dumps(result)
|
||||
except (TypeError, ValueError): # noqa: BLE001 — non-JSON tool results (e.g. PreparedTool) degrade to an error marker, never crash the loop
|
||||
content = json.dumps({"success": False, "error": "tool result not JSON serializable"})
|
||||
results.append(
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": call.get("id", ""),
|
||||
"content": json.dumps(result),
|
||||
"content": content,
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
Reference in new issue
Block a user