Files
omnigent-ai--omnigent/tests/test_databricks_model_discovery.py
Edwin He 65021dc1e8 fix(databricks): resolve harness launch models from the workspace (#4915)
* fix(databricks): resolve harness launch models from the workspace

The Databricks AI Gateway has retired the legacy `databricks-*` model
namespace (`501 NOT_IMPLEMENTED ... Use Unity Catalog model services (v3)`).
Several managed harnesses take their launch model from the bundled MLflow
provider catalog, whose Databricks ids carry exactly that retired spelling,
so every gateway turn fails. `claude-native` was migrated to live Unity
Catalog discovery in July; its siblings were left behind.

- codex-native: `_resolve_databricks_codex_model` resolves through the live
  UC model-services listing (ids are `system.ai.` by construction), then
  ucode's cached copy, then the bundled catalog as a documented last resort.
  An explicit legacy `model_override` is matched against the servable ids on
  the bare id, so it recovers instead of failing forever; a model the
  workspace does not serve passes through untouched.
- claude-sdk (Polly, Debby): resolve the launch model from the live listing
  using the family precedence claude-native itself falls back to. And on a
  real Databricks AI Gateway, negotiate betas (`CLAUDE_CODE_USE_GATEWAY`)
  instead of setting `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS`, which made
  Claude Code strip `interleaved-thinking` and the gateway reject the blocks
  with `400 ... Expected 'thinking'`. Unset an inherited disable flag around
  the spawn, scoped to gateway launches; a non-Databricks/mock gateway keeps
  the original workaround.
- pi-native: resolve the launch model from the live listing.
- model_catalog.fetch_databricks_model_service_entries: scope the UC listing
  to `schemas/system.ai` and paginate. Unscoped and unpaged it walked the
  whole metastore and returned one page of whatever schemas sorted first, so
  a workspace serving 53 models reported 2 and zero Claude entries. A repeated
  page token returns the pages collected so far (a partial `system.ai` list
  still launches) rather than raising, since callers treat an exception as
  "no listing" and fall back to the retired `databricks-` catalog.

Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>

* test(databricks): keep codex build test offline

build_codex_native_server now resolves the launch model through live
Unity Catalog discovery, so a build with a profile makes a real
model-services call. test_build_codex_native_server_uses_profile_host_without_static_token
passed only on a machine with ambient Databricks credentials and crashed
the CI worker on the network call. Stub discovery offline; the test
asserts the profile-host base URL + auth command, not model resolution.

Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>

---------

Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
2026-08-18 04:32:05 +00:00

374 lines
14 KiB
Python

"""Tests for live Databricks Claude model discovery."""
from __future__ import annotations
import httpx
import pytest
from omnigent.databricks_model_discovery import discover_databricks_claude_catalog
# One-shot stubs for the two discovery endpoints: ``(status, json_payload)``,
# with a ``None`` payload meaning "no body" (a bare error response).
_Stub = tuple[int, object | None]
_UC_EMPTY: _Stub = (200, {"model_services": []})
_UC_DOWN: _Stub = (503, None)
_GATEWAY_EMPTY: _Stub = (200, {"data": []})
_GATEWAY_GONE: _Stub = (404, None)
def _discover(uc: _Stub, gateway: _Stub) -> object:
"""Run discovery against canned UC / legacy-gateway responses."""
def _handler(request: httpx.Request) -> httpx.Response:
status, payload = uc if request.url.path.endswith("/model-services") else gateway
if payload is None:
return httpx.Response(status, request=request)
return httpx.Response(status, json=payload, request=request)
return discover_databricks_claude_catalog(
"https://workspace.example.com",
"token",
transport=httpx.MockTransport(_handler),
)
def test_model_services_are_paginated_filtered_and_version_sorted() -> None:
"""The UC listing keeps system Claude services and chooses newest versions."""
requests: list[httpx.Request] = []
def _handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
assert request.headers["authorization"] == "Bearer token"
page_token = request.url.params.get("page_token")
if page_token is None:
return httpx.Response(
200,
json={
"model_services": [
{"name": "model-services/system.ai.claude-opus-4-9"},
{"name": "model-services/main.ai.claude-opus-99"},
{"name": "model-services/system.ai.gpt-5-5"},
],
"next_page_token": "next",
},
)
assert page_token == "next"
return httpx.Response(
200,
json={
"model_services": [
{"name": "model-services/system.ai.claude-opus-4-10"},
{"name": "model-services/system.ai.claude-sonnet-4-6"},
{"name": "model-services/system.ai.claude-sonnet-5"},
{"name": "system.ai.claude-haiku-4-5"},
]
},
)
families = discover_databricks_claude_catalog(
"https://workspace.example.com/",
"token",
transport=httpx.MockTransport(_handler),
).families
assert families == {
"opus": "system.ai.claude-opus-4-10",
"sonnet": "system.ai.claude-sonnet-5",
"haiku": "system.ai.claude-haiku-4-5",
}
# Both UC pages, then the legacy gateway (always consulted so the spelling
# preference is deterministic).
assert len(requests) == 3
assert requests[0].url.params["max_results"] == "1000"
assert requests[0].url.params["parent"] == "schemas/system.ai"
def test_anthropic_gateway_is_the_legacy_fallback() -> None:
"""A workspace without UC Claude services falls back to ``/v1/models``."""
paths: list[str] = []
def _handler(request: httpx.Request) -> httpx.Response:
paths.append(request.url.path)
if request.url.path.endswith("/model-services"):
return httpx.Response(
200,
json={"model_services": [{"name": "model-services/system.ai.gpt-5-5"}]},
)
return httpx.Response(
200,
json={
"data": [
{"id": "databricks-claude-opus-4-8"},
{"id": "databricks-claude-3-7-sonnet"},
{"id": "databricks-claude-3-5-haiku"},
{"id": "databricks-claude-sonnet-4-6-anthropic"},
]
},
)
families = discover_databricks_claude_catalog(
"https://workspace.example.com",
"token",
transport=httpx.MockTransport(_handler),
).families
assert families == {
"opus": "databricks-claude-opus-4-8",
"sonnet": "databricks-claude-3-7-sonnet",
"haiku": "databricks-claude-3-5-haiku",
}
assert paths == [
"/api/2.1/unity-catalog/model-services",
"/ai-gateway/anthropic/v1/models",
]
@pytest.mark.parametrize(
("uc", "gateway", "expected_families", "expected_model_ids"),
[
# Two successful empty listings return empty instead of inventing models.
(_UC_EMPTY, _GATEWAY_EMPTY, {}, ()),
# A transient model-services failure does not hide the legacy catalog.
(
_UC_DOWN,
(200, {"data": [{"id": "databricks-claude-haiku-4-5"}]}),
{"haiku": "databricks-claude-haiku-4-5"},
("databricks-claude-haiku-4-5",),
),
# Removed UC services do not revive stale models when legacy 404s.
(_UC_EMPTY, _GATEWAY_GONE, {}, ()),
# The family picks drop older generations; the catalog keeps them.
(
(
200,
{
"model_services": [
{"name": "model-services/system.ai.claude-opus-5"},
{"name": "model-services/system.ai.claude-opus-4-8"},
{"name": "model-services/system.ai.claude-sonnet-5"},
{"name": "model-services/system.ai.gpt-5-5"},
]
},
),
_GATEWAY_EMPTY,
{"opus": "system.ai.claude-opus-5", "sonnet": "system.ai.claude-sonnet-5"},
(
"system.ai.claude-sonnet-5",
"system.ai.claude-opus-5",
"system.ai.claude-opus-4-8",
),
),
# The gateway fallback reports its whole Claude list too.
(
_UC_EMPTY,
(
200,
{
"data": [
{"id": "databricks-claude-opus-5"},
{"id": "databricks-claude-opus-4-8"},
{"id": "databricks-gpt-5-5"},
]
},
),
{"opus": "databricks-claude-opus-5"},
("databricks-claude-opus-5", "databricks-claude-opus-4-8"),
),
# An authoritative listing with no Claude at all yields an empty
# catalog, not an error.
(_UC_EMPTY, (200, {"data": [{"id": "databricks-gpt-5-5"}]}), {}, ()),
],
)
def test_discovered_catalog_from_the_two_endpoints(
uc: _Stub,
gateway: _Stub,
expected_families: dict[str, str],
expected_model_ids: tuple[str, ...],
) -> None:
catalog = _discover(uc, gateway)
assert catalog.families == expected_families # type: ignore[attr-defined]
assert catalog.model_ids == expected_model_ids # type: ignore[attr-defined]
@pytest.mark.parametrize(
("uc", "gateway", "exc", "match"),
[
# A transient UC outage plus a Claude-less gateway is NOT authoritative.
# Returning ``{}`` here would make callers treat the workspace as having
# no Claude models and hard-fail the launch; the primary failure must
# surface so they fall back to cached models instead.
(_UC_DOWN, _GATEWAY_EMPTY, httpx.HTTPStatusError, None),
# Same contract when the gateway answers with only non-Claude routes.
(_UC_DOWN, (200, {"data": [{"id": "databricks-gpt-5-5"}]}), httpx.HTTPStatusError, None),
# A total discovery outage is distinct from an authoritative empty list.
(_UC_DOWN, (503, None), httpx.HTTPStatusError, None),
# Malformed success payloads cannot be mistaken for removed models.
(
(200, ["not", "an", "object"]),
(200, ["not", "an", "object"]),
ValueError,
"must be an object",
),
],
)
def test_discovery_failures_raise_instead_of_reporting_empty(
uc: _Stub,
gateway: _Stub,
exc: type[Exception],
match: str | None,
) -> None:
with pytest.raises(exc, match=match):
_discover(uc, gateway)
def test_truncated_pagination_warns(caplog: pytest.LogCaptureFixture) -> None:
"""Exhausting the page budget with more pages pending logs a warning."""
def _handler(request: httpx.Request) -> httpx.Response:
token = request.url.params.get("page_token", "0")
return httpx.Response(
200,
json={
"model_services": [{"name": f"model-services/system.ai.claude-opus-{token}"}],
"next_page_token": str(int(token) + 1),
},
)
with caplog.at_level("WARNING", logger="omnigent.databricks_model_discovery"):
families = discover_databricks_claude_catalog(
"https://workspace.example.com",
"token",
transport=httpx.MockTransport(_handler),
).families
assert families == {"opus": "system.ai.claude-opus-99"}
assert any("truncated" in record.message for record in caplog.records)
def test_duplicate_spellings_collapse_onto_the_databricks_id() -> None:
"""A workspace serving both spellings names each model one way, every time."""
catalog = _discover(
(
200,
{
"model_services": [
{"name": "model-services/system.ai.claude-opus-5"},
{"name": "model-services/system.ai.claude-opus-4-8"},
{"name": "model-services/system.ai.claude-sonnet-5"},
]
},
),
(
200,
{
"data": [
{"id": "databricks-claude-opus-5"},
{"id": "databricks-claude-opus-4-8"},
{"id": "databricks-claude-sonnet-5"},
]
},
),
)
assert catalog.families == { # type: ignore[attr-defined]
"opus": "databricks-claude-opus-5",
"sonnet": "databricks-claude-sonnet-5",
}
assert catalog.model_ids == ( # type: ignore[attr-defined]
"databricks-claude-sonnet-5",
"databricks-claude-opus-5",
"databricks-claude-opus-4-8",
)
def test_a_model_only_unity_catalog_serves_keeps_its_own_spelling() -> None:
catalog = _discover(
(200, {"model_services": [{"name": "model-services/system.ai.claude-haiku-4-5"}]}),
(200, {"data": [{"id": "databricks-claude-opus-5"}]}),
)
assert catalog.families == { # type: ignore[attr-defined]
"opus": "databricks-claude-opus-5",
"haiku": "system.ai.claude-haiku-4-5",
}
def test_family_shim_warns_and_delegates_to_the_catalog() -> None:
from omnigent.databricks_model_discovery import discover_databricks_claude_models
def _handler(request: httpx.Request) -> httpx.Response:
payload = (
{"model_services": [{"name": "model-services/system.ai.claude-opus-5"}]}
if request.url.path.endswith("/model-services")
else {"data": []}
)
return httpx.Response(200, json=payload, request=request)
with pytest.warns(DeprecationWarning, match="v0.10.0"):
families = discover_databricks_claude_models(
"https://workspace.example.com",
"token",
transport=httpx.MockTransport(_handler),
)
assert families == {"opus": "system.ai.claude-opus-5"}
def _discover_codex(model_services: list[dict[str, str]]) -> tuple[str, ...]:
"""Run codex discovery against a canned ``system.ai`` model-services page."""
from omnigent.databricks_model_discovery import discover_databricks_codex_models
def _handler(request: httpx.Request) -> httpx.Response:
assert request.url.params["parent"] == "schemas/system.ai"
return httpx.Response(200, json={"model_services": model_services}, request=request)
return discover_databricks_codex_models(
"https://workspace.example.com",
"token",
transport=httpx.MockTransport(_handler),
)
def test_discover_codex_models_filters_non_codex_and_ranks_curated_first() -> None:
"""Codex discovery keeps only codex-servable ids, curated catalog first.
The listing says what a workspace *can* serve, not which to launch on:
the owned curated codex catalog leads (in its declared order), then
versioned GPT ids newest-first, then the rest — and non-codex families
(Claude) are dropped entirely.
"""
servable = _discover_codex(
[
{"name": "model-services/system.ai.gpt-5-5"},
{"name": "model-services/system.ai.gpt-5-6-luna"},
{"name": "model-services/system.ai.gpt-5-6-sol"},
{"name": "model-services/system.ai.gpt-6-1"},
{"name": "model-services/system.ai.kimi-k2"},
{"name": "model-services/system.ai.claude-opus-5"},
]
)
assert servable == (
# Curated (cheapest-safe-first) beats a newer non-curated generation.
"system.ai.gpt-5-6-sol",
"system.ai.gpt-5-6-luna",
"system.ai.gpt-5-5",
# Non-curated GPT, newest generation next.
"system.ai.gpt-6-1",
# Codex-compatible but unversioned, last.
"system.ai.kimi-k2",
)
def test_discover_codex_models_empty_listing_is_authoritative() -> None:
"""A listing that serves no codex model returns an empty tuple, not an error."""
assert _discover_codex([{"name": "model-services/system.ai.claude-opus-5"}]) == ()
def test_select_servable_model_matches_legacy_spelling() -> None:
"""A legacy ``databricks-`` request resolves to the served ``system.ai.`` id."""
from omnigent.databricks_model_discovery import select_servable_model
servable = ("system.ai.gpt-5-6-luna", "system.ai.gpt-5-5")
assert select_servable_model("databricks-gpt-5-6-luna", servable) == "system.ai.gpt-5-6-luna"
# A model the workspace does not serve is left for the caller to pass through.
assert select_servable_model("databricks-gpt-9-9", servable) is None