Skip to content

Commit c84bd74

Browse files
committed
Vet ops provider_tables and resolve_openai_chat_model; pin the contract at dd3d3be
provider_tables serializes the reference's provider tables, published by lm15-contract as tables/providers.json for the ports to generate theirs from. resolve_openai_chat_model drives the contract's litellm-prefix router cases. Signed-off-by: Maxime Rivest <mrive052@gmail.com>
1 parent d9d530b commit c84bd74

5 files changed

Lines changed: 182 additions & 1 deletion

File tree

‎CONTRACT_PIN‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
57e33d17f562f932df920e6c6e876d9d8799c53b
1+
dd3d3bec9f3411c332ae934d4b934e66437e1846

‎lm15/_vet_tables.py‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""The provider tables, as data, for the vet protocol's ``provider_tables`` op.
2+
3+
``lm15-contract/tables/providers.json`` is this function's output, and
4+
``tools/audit.py`` there fails when the two differ. Ports generate their
5+
copies from that file (``playbooks/port.md`` rule 2: tables are data, copied,
6+
never re-derived). This module reads the tables the reference uses at import
7+
time and serializes them; it adds no fact of its own.
8+
9+
Shape rules, so a generator never guesses:
10+
11+
- Compat policies list only the knobs a preset sets (``None`` means
12+
"inherit", so an absent key is exactly the value), with
13+
``model_overrides`` as ``[prefix, {knob: value}]`` pairs in match order.
14+
- Access policies, host specs and endpoint support are written whole, every
15+
field, so a port never depends on its own defaults agreeing with these.
16+
- Tuples become arrays and keep their order; mappings keep insertion order;
17+
a frozenset (``EndpointSupport.extra``) becomes a sorted array.
18+
- Field names are the reference's (snake_case); a port renames them.
19+
"""
20+
21+
from __future__ import annotations
22+
23+
import dataclasses
24+
from collections.abc import Mapping
25+
from typing import Any
26+
27+
SCHEMA = 1
28+
29+
30+
_NO_DEFAULT = object()
31+
32+
33+
def _default(f: dataclasses.Field) -> Any:
34+
if f.default is not dataclasses.MISSING:
35+
return f.default
36+
if f.default_factory is not dataclasses.MISSING: # type: ignore[misc]
37+
return f.default_factory() # type: ignore[misc]
38+
return _NO_DEFAULT
39+
40+
41+
def _plain(value: Any, *, elide: tuple[type, ...]) -> Any:
42+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
43+
out: dict[str, Any] = {}
44+
for f in dataclasses.fields(value):
45+
v = getattr(value, f.name)
46+
if isinstance(value, elide) and v == _default(f):
47+
continue # a compat knob at its default inherits
48+
out[f.name] = _plain(v, elide=elide)
49+
return out
50+
if isinstance(value, Mapping):
51+
return {str(k): _plain(v, elide=elide) for k, v in value.items()}
52+
if isinstance(value, (frozenset, set)):
53+
return sorted(_plain(v, elide=elide) for v in value)
54+
if isinstance(value, (list, tuple)):
55+
return [_plain(v, elide=elide) for v in value]
56+
if value is None or isinstance(value, (str, int, float, bool)):
57+
return value
58+
raise TypeError(f"provider_tables: no JSON form for {type(value).__name__}")
59+
60+
61+
def provider_tables() -> dict[str, Any]:
62+
from . import compat as _compat
63+
from .login import declared as _declared
64+
from .login import flows as _flows
65+
from . import registry as _registry
66+
from . import router as _router
67+
68+
elide = (_compat.OpenAIChatCompat, _compat.OpenAIResponsesCompat, _compat.AnthropicCompat)
69+
70+
def plain(value: Any) -> Any:
71+
return _plain(value, elide=elide)
72+
73+
def row(definition: Any) -> dict[str, Any]:
74+
compat = definition.compat
75+
return {
76+
"id": definition.id,
77+
"dialect": definition.dialect,
78+
"kind": "hosted" if definition.hosted else ("bound" if definition.bound else "adapter-owned"),
79+
"compat": compat if compat is None or isinstance(compat, str) else plain(compat),
80+
"access": plain(definition.access),
81+
"aliases": list(definition.aliases),
82+
"placeholder_key": definition.placeholder_key,
83+
"console_url": definition.console_url,
84+
"note": definition.note,
85+
}
86+
87+
return {
88+
"schema": SCHEMA,
89+
"providers": [row(d) for d in _registry.PROVIDERS.values()],
90+
"declared_login": [row(d) for d in _declared.DECLARED_PROVIDERS],
91+
"compat": {
92+
"chat": plain(_compat.OPENAI_CHAT_PRESETS),
93+
"chat_base_urls": plain(_compat.OPENAI_CHAT_PRESET_BASE_URLS),
94+
"responses": plain(_compat.OPENAI_RESPONSES_PRESETS),
95+
"responses_base_urls": plain(_compat.OPENAI_RESPONSES_PRESET_BASE_URLS),
96+
"anthropic": plain(_compat.ANTHROPIC_PRESETS),
97+
"anthropic_base_urls": plain(_compat.ANTHROPIC_PRESET_BASE_URLS),
98+
"preset_aliases": plain(_compat._OPENAI_CHAT_PRESET_ALIASES),
99+
},
100+
"routing": {
101+
"default_rules": [{"prefix": r.prefix, "provider": r.provider, "note": r.note} for r in _router.DEFAULT_RULES],
102+
"litellm_prefixes": plain(_router.LITELLM_PROVIDER_PREFIXES),
103+
},
104+
"login": {"service_labels": plain(_flows._SERVICE_LABELS)},
105+
}

‎lm15/vet.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,25 @@ def op_resolve_model(msg: JsonObject) -> JsonObject:
349349
return {"provider": resolution.provider, "model": resolution.model, "source": resolution.source}
350350

351351

352+
def op_resolve_openai_chat_model(msg: JsonObject) -> JsonObject:
353+
"""PROTOCOL.md resolve_openai_chat_model: the router's
354+
``resolve_openai_chat`` (the OpenAI SDK and litellm spellings, read by
355+
``openai_chat_model_string``) over harness-supplied inputs only."""
356+
from .router import LMRouter, RouterConfig
357+
358+
env = {str(k): str(v) for k, v in (msg.get("env") or {}).items()}
359+
resolution = LMRouter(RouterConfig(env=env)).resolve_openai_chat(str(msg["model"]))
360+
return {"provider": resolution.provider, "model": resolution.model, "source": resolution.source}
361+
362+
363+
def op_provider_tables(msg: JsonObject) -> JsonObject:
364+
"""PROTOCOL.md provider_tables (reference only): the registry, access
365+
policies, compat presets and router tables, as data."""
366+
from ._vet_tables import provider_tables
367+
368+
return provider_tables()
369+
370+
352371
def op_build_models_request(msg: JsonObject) -> JsonObject:
353372
lm = _adapter(msg)
354373
return normalize_transport_request(lm._models_request())
@@ -812,6 +831,8 @@ def _string_vocabulary(obj: Any) -> list[str] | None:
812831
"surface_dump": op_surface_dump,
813832
"explain_auth": op_explain_auth,
814833
"resolve_model": op_resolve_model,
834+
"resolve_openai_chat_model": op_resolve_openai_chat_model,
835+
"provider_tables": op_provider_tables,
815836
"token_exchange_build": op_token_exchange_build,
816837
"token_exchange_parse": op_token_exchange_parse,
817838
"sigv4_sign": op_sigv4_sign,

‎tests/test_vet_shim.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,11 @@
4040
"normalize_error",
4141
"parse_models_response",
4242
"parse_response",
43+
"provider_tables",
4344
"replay_live",
4445
"replay_stream",
4546
"resolve_model",
47+
"resolve_openai_chat_model",
4648
"serde_roundtrip",
4749
"sigv4_sign",
4850
"surface_dump",

‎tests/test_vet_tables.py‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""The vet shim's provider_tables and resolve_openai_chat_model ops.
2+
3+
provider_tables is what lm15-contract publishes as tables/providers.json and
4+
what the TypeScript, Rust and Go ports generate their tables from, so it must
5+
be exact JSON of the tables the reference itself uses.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import json
11+
12+
import pytest
13+
14+
from lm15 import compat, registry, router
15+
from lm15.errors import UnknownModelError
16+
from lm15.vet import op_provider_tables, op_resolve_openai_chat_model
17+
18+
19+
def test_tables_are_json_and_mirror_the_registry() -> None:
20+
tables = op_provider_tables({})
21+
assert json.loads(json.dumps(tables)) == tables # JSON values only, nothing lost
22+
assert tables["schema"] == 1
23+
assert [row["id"] for row in tables["providers"]] == list(registry.PROVIDERS)
24+
for row in tables["providers"]:
25+
definition = registry.PROVIDERS[row["id"]]
26+
assert row["access"]["provider"] == definition.access.provider
27+
assert row["access"]["env_keys"] == list(definition.access.env_keys)
28+
assert row["kind"] == ("hosted" if definition.hosted else "bound" if definition.bound else "adapter-owned")
29+
assert row["note"] == definition.note
30+
31+
32+
def test_compat_presets_list_only_the_knobs_they_set() -> None:
33+
tables = op_provider_tables({})
34+
assert set(tables["compat"]["chat"]) == set(compat.OPENAI_CHAT_PRESETS)
35+
deepinfra = tables["compat"]["chat"]["deepinfra"]
36+
assert "routing" not in deepinfra # unset knobs inherit and are absent
37+
prefix, knobs = deepinfra["model_overrides"][0]
38+
assert isinstance(prefix, str) and isinstance(knobs, dict)
39+
assert tables["compat"]["anthropic"]["anthropic"] == {}
40+
41+
42+
def test_router_tables_in_match_order() -> None:
43+
tables = op_provider_tables({})
44+
assert [r["prefix"] for r in tables["routing"]["default_rules"]] == [r.prefix for r in router.DEFAULT_RULES]
45+
assert tables["routing"]["litellm_prefixes"] == dict(router.LITELLM_PROVIDER_PREFIXES)
46+
47+
48+
def test_resolve_openai_chat_model_reads_litellm_spellings() -> None:
49+
assert op_resolve_openai_chat_model({"model": "groq/openai/gpt-oss-20b", "env": {}}) == {
50+
"provider": "groq", "model": "openai/gpt-oss-20b", "source": "prefix"}
51+
assert op_resolve_openai_chat_model({"model": "gpt-4.1-mini", "env": {}})["provider"] == "openai-chat"
52+
with pytest.raises(UnknownModelError):
53+
op_resolve_openai_chat_model({"model": "bedrock/anthropic.claude-sonnet-5", "env": {}})

0 commit comments

Comments
 (0)