dbx-tools-model 0.6.78__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dbx_tools_model-0.6.78/PKG-INFO +60 -0
- dbx_tools_model-0.6.78/README.md +50 -0
- dbx_tools_model-0.6.78/pyproject.toml +22 -0
- dbx_tools_model-0.6.78/pyproject.toml.orig +21 -0
- dbx_tools_model-0.6.78/src/dbx_tools/model/__init__.py +113 -0
- dbx_tools_model-0.6.78/src/dbx_tools/model/chat.py +56 -0
- dbx_tools_model-0.6.78/src/dbx_tools/model/classes.py +33 -0
- dbx_tools_model-0.6.78/src/dbx_tools/model/classify.py +181 -0
- dbx_tools_model-0.6.78/src/dbx_tools/model/embedding.py +36 -0
- dbx_tools_model-0.6.78/src/dbx_tools/model/fallback.py +40 -0
- dbx_tools_model-0.6.78/src/dbx_tools/model/invoke.py +81 -0
- dbx_tools_model-0.6.78/src/dbx_tools/model/models.py +68 -0
- dbx_tools_model-0.6.78/src/dbx_tools/model/resolve.py +218 -0
- dbx_tools_model-0.6.78/src/dbx_tools/model/serving.py +126 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: dbx-tools-model
|
|
3
|
+
Version: 0.6.78
|
|
4
|
+
Summary: Databricks Model Serving invocation, classification, and endpoint resolution
|
|
5
|
+
Requires-Dist: databricks-sdk>=0.63.0,<1
|
|
6
|
+
Requires-Dist: pydantic>=2.9,<3
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Project-URL: Source, https://github.com/reggie-db/dbx-tools/tree/main/packages/py/model
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# `dbx-tools-model`
|
|
12
|
+
|
|
13
|
+
Python contracts and runtime helpers for Databricks Model Serving. This package
|
|
14
|
+
mirrors the reusable parts of `@dbx-tools/shared-model` and `@dbx-tools/model`
|
|
15
|
+
without AppKit cache or Mastra dependencies.
|
|
16
|
+
|
|
17
|
+
Install directly from this monorepo:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install "dbx-tools-model @ git+https://github.com/reggie-db/dbx-tools.git@main#subdirectory=packages/py/model"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Key features:
|
|
24
|
+
|
|
25
|
+
- stable Pydantic endpoint, profile, query, and ranked-result models;
|
|
26
|
+
- live endpoint listing through a structural `WorkspaceClient` protocol;
|
|
27
|
+
- score-driven model classification with family fallbacks;
|
|
28
|
+
- exact and fuzzy endpoint resolution with deterministic class ordering;
|
|
29
|
+
- Databricks invocation URL and per-request authentication helpers;
|
|
30
|
+
- OpenAI chat request sanitization and content extraction;
|
|
31
|
+
- embedding vector extraction with optional dimension validation.
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from databricks.sdk import WorkspaceClient
|
|
35
|
+
from dbx_tools.model import ModelClass, list_serving_endpoints, resolve_model
|
|
36
|
+
|
|
37
|
+
endpoints = list_serving_endpoints(WorkspaceClient())
|
|
38
|
+
selection = resolve_model(endpoints, model_class=ModelClass.CHAT_BALANCED)
|
|
39
|
+
print(selection.model_id)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The Python port intentionally omits AppKit `CacheManager` integration,
|
|
43
|
+
Mastra-specific adapters, and browser-only schemas. Callers can cache the plain
|
|
44
|
+
Pydantic results with their preferred Python cache.
|
|
45
|
+
|
|
46
|
+
## Relationship to the Databricks SDK
|
|
47
|
+
|
|
48
|
+
Use the native SDK directly when an endpoint name is already known and its typed
|
|
49
|
+
query method fits the request. Use this package when endpoint choice, stable
|
|
50
|
+
cross-runtime models, OpenAI-shaped HTTP invocation, or provider-neutral chat
|
|
51
|
+
and embedding normalization is the repetitive part.
|
|
52
|
+
|
|
53
|
+
## Module map
|
|
54
|
+
|
|
55
|
+
- `models` — Pydantic wire contracts;
|
|
56
|
+
- `classify`, `classes`, `fallback` — model taxonomy and ordering;
|
|
57
|
+
- `resolve` — exact/fuzzy ranking and single-model selection;
|
|
58
|
+
- `serving` — structural `WorkspaceClient` endpoint listing;
|
|
59
|
+
- `invoke` — URLs, SDK authentication headers, and JSON POST helpers;
|
|
60
|
+
- `chat`, `embedding` — request sanitization and response extraction.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# `dbx-tools-model`
|
|
2
|
+
|
|
3
|
+
Python contracts and runtime helpers for Databricks Model Serving. This package
|
|
4
|
+
mirrors the reusable parts of `@dbx-tools/shared-model` and `@dbx-tools/model`
|
|
5
|
+
without AppKit cache or Mastra dependencies.
|
|
6
|
+
|
|
7
|
+
Install directly from this monorepo:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install "dbx-tools-model @ git+https://github.com/reggie-db/dbx-tools.git@main#subdirectory=packages/py/model"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Key features:
|
|
14
|
+
|
|
15
|
+
- stable Pydantic endpoint, profile, query, and ranked-result models;
|
|
16
|
+
- live endpoint listing through a structural `WorkspaceClient` protocol;
|
|
17
|
+
- score-driven model classification with family fallbacks;
|
|
18
|
+
- exact and fuzzy endpoint resolution with deterministic class ordering;
|
|
19
|
+
- Databricks invocation URL and per-request authentication helpers;
|
|
20
|
+
- OpenAI chat request sanitization and content extraction;
|
|
21
|
+
- embedding vector extraction with optional dimension validation.
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from databricks.sdk import WorkspaceClient
|
|
25
|
+
from dbx_tools.model import ModelClass, list_serving_endpoints, resolve_model
|
|
26
|
+
|
|
27
|
+
endpoints = list_serving_endpoints(WorkspaceClient())
|
|
28
|
+
selection = resolve_model(endpoints, model_class=ModelClass.CHAT_BALANCED)
|
|
29
|
+
print(selection.model_id)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The Python port intentionally omits AppKit `CacheManager` integration,
|
|
33
|
+
Mastra-specific adapters, and browser-only schemas. Callers can cache the plain
|
|
34
|
+
Pydantic results with their preferred Python cache.
|
|
35
|
+
|
|
36
|
+
## Relationship to the Databricks SDK
|
|
37
|
+
|
|
38
|
+
Use the native SDK directly when an endpoint name is already known and its typed
|
|
39
|
+
query method fits the request. Use this package when endpoint choice, stable
|
|
40
|
+
cross-runtime models, OpenAI-shaped HTTP invocation, or provider-neutral chat
|
|
41
|
+
and embedding normalization is the repetitive part.
|
|
42
|
+
|
|
43
|
+
## Module map
|
|
44
|
+
|
|
45
|
+
- `models` — Pydantic wire contracts;
|
|
46
|
+
- `classify`, `classes`, `fallback` — model taxonomy and ordering;
|
|
47
|
+
- `resolve` — exact/fuzzy ranking and single-model selection;
|
|
48
|
+
- `serving` — structural `WorkspaceClient` endpoint listing;
|
|
49
|
+
- `invoke` — URLs, SDK authentication headers, and JSON POST helpers;
|
|
50
|
+
- `chat`, `embedding` — request sanitization and response extraction.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "dbx-tools-model"
|
|
3
|
+
version = "0.6.78"
|
|
4
|
+
description = "Databricks Model Serving invocation, classification, and endpoint resolution"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"databricks-sdk>=0.63.0,<1",
|
|
9
|
+
"pydantic>=2.9,<3",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[project.urls]
|
|
13
|
+
Source = "https://github.com/reggie-db/dbx-tools/tree/main/packages/py/model"
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["uv_build>=0.11.28,<0.12.0"]
|
|
17
|
+
build-backend = "uv_build"
|
|
18
|
+
|
|
19
|
+
[tool.uv.build-backend]
|
|
20
|
+
module-name = "dbx_tools.model"
|
|
21
|
+
module-root = "src"
|
|
22
|
+
namespace = true
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# ~~ Generated by projen. To modify, edit .projenrc.js and run "bunx projen".
|
|
2
|
+
|
|
3
|
+
[project]
|
|
4
|
+
name = "dbx-tools-model"
|
|
5
|
+
version = "0.6.78"
|
|
6
|
+
description = "Databricks Model Serving invocation, classification, and endpoint resolution"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
requires-python = ">=3.10"
|
|
9
|
+
dependencies = [ "databricks-sdk>=0.63.0,<1", "pydantic>=2.9,<3" ]
|
|
10
|
+
|
|
11
|
+
[project.urls]
|
|
12
|
+
Source = "https://github.com/reggie-db/dbx-tools/tree/main/packages/py/model"
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = [ "uv_build>=0.11.28,<0.12.0" ]
|
|
16
|
+
build-backend = "uv_build"
|
|
17
|
+
|
|
18
|
+
[tool.uv.build-backend]
|
|
19
|
+
module-name = "dbx_tools.model"
|
|
20
|
+
module-root = "src"
|
|
21
|
+
namespace = true
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from .chat import (
|
|
2
|
+
UNSUPPORTED_CHAT_FIELDS,
|
|
3
|
+
chat_content_to_text,
|
|
4
|
+
sanitize_chat_request,
|
|
5
|
+
strip_unsupported_chat_fields,
|
|
6
|
+
)
|
|
7
|
+
from .classes import (
|
|
8
|
+
CHAT_CLASS_ORDER,
|
|
9
|
+
MODEL_CLASS_ORDER,
|
|
10
|
+
classes_at_or_below,
|
|
11
|
+
is_chat_class,
|
|
12
|
+
parse_model_class,
|
|
13
|
+
)
|
|
14
|
+
from .classify import (
|
|
15
|
+
CHAT_TASK,
|
|
16
|
+
EMBEDDING_TASK,
|
|
17
|
+
classify_by_family,
|
|
18
|
+
classify_endpoints,
|
|
19
|
+
endpoint_capabilities,
|
|
20
|
+
supports_tools_by_family,
|
|
21
|
+
version_tuple,
|
|
22
|
+
)
|
|
23
|
+
from .embedding import embedding_dimension, extract_embedding, extract_embeddings
|
|
24
|
+
from .fallback import FALLBACK_MODEL_IDS, model_for_class, models_for_class
|
|
25
|
+
from .invoke import (
|
|
26
|
+
CHAT_COMPLETIONS_PATH,
|
|
27
|
+
INVOCATIONS_SUFFIX,
|
|
28
|
+
OPEN_RESPONSES_PATH,
|
|
29
|
+
RESPONSES_PATH,
|
|
30
|
+
auth_headers,
|
|
31
|
+
chat_completions_url,
|
|
32
|
+
invocations_url,
|
|
33
|
+
invoke_json,
|
|
34
|
+
is_responses_only,
|
|
35
|
+
open_responses_url,
|
|
36
|
+
post_json,
|
|
37
|
+
responses_upstream_url,
|
|
38
|
+
responses_url,
|
|
39
|
+
)
|
|
40
|
+
from .models import (
|
|
41
|
+
EndpointCapabilities,
|
|
42
|
+
ModelClass,
|
|
43
|
+
ModelProfile,
|
|
44
|
+
ModelQuery,
|
|
45
|
+
RankedModel,
|
|
46
|
+
ResolvedModel,
|
|
47
|
+
ResolvedModelSelection,
|
|
48
|
+
ServingEndpointSummary,
|
|
49
|
+
)
|
|
50
|
+
from .resolve import (
|
|
51
|
+
DEFAULT_FUZZY_THRESHOLD,
|
|
52
|
+
rank_model_id,
|
|
53
|
+
rank_models,
|
|
54
|
+
resolve_model,
|
|
55
|
+
resolve_model_id,
|
|
56
|
+
search_serving_endpoints,
|
|
57
|
+
)
|
|
58
|
+
from .serving import list_serving_endpoints, list_serving_endpoints_uncached, to_model_display_name
|
|
59
|
+
|
|
60
|
+
__all__ = [
|
|
61
|
+
"CHAT_CLASS_ORDER",
|
|
62
|
+
"CHAT_COMPLETIONS_PATH",
|
|
63
|
+
"CHAT_TASK",
|
|
64
|
+
"DEFAULT_FUZZY_THRESHOLD",
|
|
65
|
+
"EMBEDDING_TASK",
|
|
66
|
+
"FALLBACK_MODEL_IDS",
|
|
67
|
+
"INVOCATIONS_SUFFIX",
|
|
68
|
+
"MODEL_CLASS_ORDER",
|
|
69
|
+
"OPEN_RESPONSES_PATH",
|
|
70
|
+
"RESPONSES_PATH",
|
|
71
|
+
"UNSUPPORTED_CHAT_FIELDS",
|
|
72
|
+
"EndpointCapabilities",
|
|
73
|
+
"ModelClass",
|
|
74
|
+
"ModelProfile",
|
|
75
|
+
"ModelQuery",
|
|
76
|
+
"RankedModel",
|
|
77
|
+
"ResolvedModel",
|
|
78
|
+
"ResolvedModelSelection",
|
|
79
|
+
"ServingEndpointSummary",
|
|
80
|
+
"auth_headers",
|
|
81
|
+
"chat_completions_url",
|
|
82
|
+
"chat_content_to_text",
|
|
83
|
+
"classes_at_or_below",
|
|
84
|
+
"classify_by_family",
|
|
85
|
+
"classify_endpoints",
|
|
86
|
+
"embedding_dimension",
|
|
87
|
+
"endpoint_capabilities",
|
|
88
|
+
"extract_embedding",
|
|
89
|
+
"extract_embeddings",
|
|
90
|
+
"invocations_url",
|
|
91
|
+
"invoke_json",
|
|
92
|
+
"is_chat_class",
|
|
93
|
+
"is_responses_only",
|
|
94
|
+
"list_serving_endpoints",
|
|
95
|
+
"list_serving_endpoints_uncached",
|
|
96
|
+
"model_for_class",
|
|
97
|
+
"models_for_class",
|
|
98
|
+
"open_responses_url",
|
|
99
|
+
"parse_model_class",
|
|
100
|
+
"post_json",
|
|
101
|
+
"rank_model_id",
|
|
102
|
+
"rank_models",
|
|
103
|
+
"resolve_model",
|
|
104
|
+
"resolve_model_id",
|
|
105
|
+
"responses_upstream_url",
|
|
106
|
+
"responses_url",
|
|
107
|
+
"sanitize_chat_request",
|
|
108
|
+
"search_serving_endpoints",
|
|
109
|
+
"strip_unsupported_chat_fields",
|
|
110
|
+
"supports_tools_by_family",
|
|
111
|
+
"to_model_display_name",
|
|
112
|
+
"version_tuple",
|
|
113
|
+
]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
|
|
5
|
+
UNSUPPORTED_CHAT_FIELDS = (
|
|
6
|
+
"parallel_tool_calls",
|
|
7
|
+
"store",
|
|
8
|
+
"metadata",
|
|
9
|
+
"service_tier",
|
|
10
|
+
"prompt_cache_key",
|
|
11
|
+
"safety_identifier",
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def strip_unsupported_chat_fields(body: dict[str, object], extra: Iterable[str] = ()) -> list[str]:
|
|
16
|
+
dropped = []
|
|
17
|
+
for field in (*UNSUPPORTED_CHAT_FIELDS, *extra):
|
|
18
|
+
if field in body:
|
|
19
|
+
del body[field]
|
|
20
|
+
dropped.append(field)
|
|
21
|
+
return dropped
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def sanitize_chat_request(body: dict[str, object], extra: Iterable[str] = ()) -> dict[str, object]:
|
|
25
|
+
sanitized = dict(body)
|
|
26
|
+
strip_unsupported_chat_fields(sanitized, extra)
|
|
27
|
+
return sanitized
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def chat_content_to_text(
|
|
31
|
+
content: object,
|
|
32
|
+
options: dict[str, object] | None = None,
|
|
33
|
+
*,
|
|
34
|
+
separator: str = "",
|
|
35
|
+
types: Iterable[str] | None = None,
|
|
36
|
+
) -> str:
|
|
37
|
+
if options:
|
|
38
|
+
separator = str(options.get("separator", separator))
|
|
39
|
+
option_types = options.get("types")
|
|
40
|
+
if isinstance(option_types, list) and all(isinstance(value, str) for value in option_types):
|
|
41
|
+
types = option_types
|
|
42
|
+
if isinstance(content, str):
|
|
43
|
+
return content
|
|
44
|
+
if not isinstance(content, list):
|
|
45
|
+
return ""
|
|
46
|
+
accepted = set(types) if types is not None else None
|
|
47
|
+
parts = []
|
|
48
|
+
for part in content:
|
|
49
|
+
if not isinstance(part, dict):
|
|
50
|
+
continue
|
|
51
|
+
if accepted is not None and part.get("type") not in accepted:
|
|
52
|
+
continue
|
|
53
|
+
text = part.get("text")
|
|
54
|
+
if isinstance(text, str):
|
|
55
|
+
parts.append(text)
|
|
56
|
+
return separator.join(parts)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .models import ModelClass
|
|
4
|
+
|
|
5
|
+
CHAT_CLASS_ORDER = (
|
|
6
|
+
ModelClass.CHAT_THINKING,
|
|
7
|
+
ModelClass.CHAT_BALANCED,
|
|
8
|
+
ModelClass.CHAT_FAST,
|
|
9
|
+
)
|
|
10
|
+
MODEL_CLASS_ORDER = (*CHAT_CLASS_ORDER, ModelClass.EMBEDDING)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def is_chat_class(model_class: ModelClass) -> bool:
|
|
14
|
+
return model_class in CHAT_CLASS_ORDER
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_model_class(value: object) -> ModelClass | None:
|
|
18
|
+
try:
|
|
19
|
+
return ModelClass(value)
|
|
20
|
+
except (TypeError, ValueError):
|
|
21
|
+
try:
|
|
22
|
+
return ModelClass(f"chat-{value}")
|
|
23
|
+
except (TypeError, ValueError):
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def classes_at_or_below(model_class: ModelClass) -> list[ModelClass]:
|
|
28
|
+
if model_class == ModelClass.EMBEDDING:
|
|
29
|
+
return [ModelClass.EMBEDDING]
|
|
30
|
+
try:
|
|
31
|
+
return list(CHAT_CLASS_ORDER[CHAT_CLASS_ORDER.index(model_class) :])
|
|
32
|
+
except ValueError:
|
|
33
|
+
return list(CHAT_CLASS_ORDER)
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
|
|
7
|
+
from .models import EndpointCapabilities, ModelClass, ServingEndpointSummary
|
|
8
|
+
|
|
9
|
+
CHAT_TASK = "llm/v1/chat"
|
|
10
|
+
EMBEDDING_TASK = "llm/v1/embeddings"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def supports_tools_by_family(name: str) -> bool:
|
|
14
|
+
normalized = name.lower()
|
|
15
|
+
if "gemini" in normalized or "gpt-oss" in normalized:
|
|
16
|
+
return False
|
|
17
|
+
return any(family in normalized for family in ("claude", "gpt", "qwen", "glm", "llama"))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def endpoint_capabilities(
|
|
21
|
+
endpoint: ServingEndpointSummary | dict[str, object],
|
|
22
|
+
) -> EndpointCapabilities:
|
|
23
|
+
summary = _summary(endpoint)
|
|
24
|
+
embedding = summary.task == EMBEDDING_TASK or summary.model_class == ModelClass.EMBEDDING
|
|
25
|
+
chat = not embedding and (summary.task == CHAT_TASK or summary.model_class is not None)
|
|
26
|
+
tools = chat and (
|
|
27
|
+
summary.supports_tools
|
|
28
|
+
if summary.supports_tools is not None
|
|
29
|
+
else supports_tools_by_family(summary.name)
|
|
30
|
+
)
|
|
31
|
+
return EndpointCapabilities(chat=chat, embedding=embedding, tools=tools)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def version_tuple(name: str) -> list[int]:
|
|
35
|
+
match = re.search(r"\d", name)
|
|
36
|
+
if match is None:
|
|
37
|
+
return [0, 0, 0]
|
|
38
|
+
numbers = []
|
|
39
|
+
for chunk in re.split(r"[^a-z0-9]+", name[match.start() :], flags=re.IGNORECASE):
|
|
40
|
+
digits = re.match(r"^\d+", chunk)
|
|
41
|
+
if digits:
|
|
42
|
+
numbers.append(int(digits.group(0)))
|
|
43
|
+
return (numbers + [0, 0, 0])[:3]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def classify_by_family(name: str) -> dict[str, object] | None:
|
|
47
|
+
normalized = name.lower()
|
|
48
|
+
|
|
49
|
+
def result(model_class: ModelClass) -> dict[str, object]:
|
|
50
|
+
major, minor, patch = version_tuple(normalized)
|
|
51
|
+
return {"class": model_class.value, "rank": major * 1_000_000 + minor * 1_000 + patch}
|
|
52
|
+
|
|
53
|
+
if "opus" in normalized:
|
|
54
|
+
return result(ModelClass.CHAT_THINKING)
|
|
55
|
+
if "sonnet" in normalized:
|
|
56
|
+
return result(ModelClass.CHAT_BALANCED)
|
|
57
|
+
if "haiku" in normalized:
|
|
58
|
+
return result(ModelClass.CHAT_FAST)
|
|
59
|
+
if "gpt-oss" in normalized:
|
|
60
|
+
return result(ModelClass.CHAT_BALANCED if "120b" in normalized else ModelClass.CHAT_FAST)
|
|
61
|
+
if "gpt" in normalized:
|
|
62
|
+
if "pro" in normalized:
|
|
63
|
+
return result(ModelClass.CHAT_THINKING)
|
|
64
|
+
if "mini" in normalized or "nano" in normalized:
|
|
65
|
+
return result(ModelClass.CHAT_FAST)
|
|
66
|
+
return result(ModelClass.CHAT_BALANCED)
|
|
67
|
+
if "gemini" in normalized:
|
|
68
|
+
if "flash-lite" in normalized:
|
|
69
|
+
return result(ModelClass.CHAT_FAST)
|
|
70
|
+
if "pro" in normalized:
|
|
71
|
+
return result(ModelClass.CHAT_THINKING)
|
|
72
|
+
return result(ModelClass.CHAT_BALANCED)
|
|
73
|
+
if "gemma" in normalized:
|
|
74
|
+
return result(ModelClass.CHAT_FAST)
|
|
75
|
+
if "llama" in normalized:
|
|
76
|
+
if "maverick" in normalized or "405b" in normalized:
|
|
77
|
+
return result(ModelClass.CHAT_THINKING)
|
|
78
|
+
if "70b" in normalized:
|
|
79
|
+
return result(ModelClass.CHAT_BALANCED)
|
|
80
|
+
if "8b" in normalized or "1b" in normalized:
|
|
81
|
+
return result(ModelClass.CHAT_FAST)
|
|
82
|
+
return result(ModelClass.CHAT_BALANCED)
|
|
83
|
+
if "qwen" in normalized:
|
|
84
|
+
return result(ModelClass.CHAT_BALANCED)
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def classify_endpoints(
|
|
89
|
+
endpoints: Iterable[ServingEndpointSummary | dict[str, object]],
|
|
90
|
+
) -> dict[str, list[dict[str, object]]]:
|
|
91
|
+
summaries = [_summary(endpoint) for endpoint in endpoints]
|
|
92
|
+
chat = [endpoint for endpoint in summaries if endpoint.task == CHAT_TASK]
|
|
93
|
+
qualities = sorted(
|
|
94
|
+
endpoint.profile.quality
|
|
95
|
+
for endpoint in chat
|
|
96
|
+
if endpoint.profile is not None
|
|
97
|
+
and endpoint.profile.quality is not None
|
|
98
|
+
and math.isfinite(endpoint.profile.quality)
|
|
99
|
+
)
|
|
100
|
+
low = _quantile(qualities, 1 / 3)
|
|
101
|
+
high = _quantile(qualities, 2 / 3)
|
|
102
|
+
buckets: dict[ModelClass, list[tuple[tuple[object, ...], ServingEndpointSummary]]] = {
|
|
103
|
+
model_class: [] for model_class in ModelClass
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for endpoint in chat:
|
|
107
|
+
quality = endpoint.profile.quality if endpoint.profile else None
|
|
108
|
+
if quality is not None and math.isfinite(quality):
|
|
109
|
+
model_class = (
|
|
110
|
+
ModelClass.CHAT_THINKING
|
|
111
|
+
if quality >= high
|
|
112
|
+
else ModelClass.CHAT_FAST
|
|
113
|
+
if quality <= low
|
|
114
|
+
else ModelClass.CHAT_BALANCED
|
|
115
|
+
)
|
|
116
|
+
cost = (
|
|
117
|
+
endpoint.profile.cost
|
|
118
|
+
if endpoint.profile and endpoint.profile.cost is not None
|
|
119
|
+
else math.inf
|
|
120
|
+
)
|
|
121
|
+
speed = (
|
|
122
|
+
endpoint.profile.speed
|
|
123
|
+
if endpoint.profile and endpoint.profile.speed is not None
|
|
124
|
+
else 0
|
|
125
|
+
)
|
|
126
|
+
key = (0, -quality, cost, -speed, *[-part for part in version_tuple(endpoint.name)])
|
|
127
|
+
else:
|
|
128
|
+
family = classify_by_family(endpoint.name)
|
|
129
|
+
if family is None:
|
|
130
|
+
continue
|
|
131
|
+
model_class = ModelClass(str(family["class"]))
|
|
132
|
+
key = (
|
|
133
|
+
1,
|
|
134
|
+
-int(family["rank"]),
|
|
135
|
+
math.inf,
|
|
136
|
+
0,
|
|
137
|
+
*[-part for part in version_tuple(endpoint.name)],
|
|
138
|
+
)
|
|
139
|
+
buckets[model_class].append((key, endpoint))
|
|
140
|
+
|
|
141
|
+
embeddings = [endpoint for endpoint in summaries if endpoint.task == EMBEDDING_TASK]
|
|
142
|
+
return {
|
|
143
|
+
ModelClass.CHAT_THINKING.value: _dump_sorted(buckets[ModelClass.CHAT_THINKING]),
|
|
144
|
+
ModelClass.CHAT_BALANCED.value: _dump_sorted(buckets[ModelClass.CHAT_BALANCED]),
|
|
145
|
+
ModelClass.CHAT_FAST.value: _dump_sorted(buckets[ModelClass.CHAT_FAST]),
|
|
146
|
+
ModelClass.EMBEDDING.value: [endpoint.as_dict() for endpoint in embeddings],
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def classified_summaries(
|
|
151
|
+
endpoints: Iterable[ServingEndpointSummary | dict[str, object]],
|
|
152
|
+
) -> dict[ModelClass, list[ServingEndpointSummary]]:
|
|
153
|
+
return {
|
|
154
|
+
ModelClass(key): [ServingEndpointSummary.model_validate(value) for value in values]
|
|
155
|
+
for key, values in classify_endpoints(endpoints).items()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _summary(value: ServingEndpointSummary | dict[str, object]) -> ServingEndpointSummary:
|
|
160
|
+
return (
|
|
161
|
+
value
|
|
162
|
+
if isinstance(value, ServingEndpointSummary)
|
|
163
|
+
else ServingEndpointSummary.model_validate(value)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _quantile(values: list[float], probability: float) -> float:
|
|
168
|
+
if not values:
|
|
169
|
+
return math.nan
|
|
170
|
+
index = (len(values) - 1) * probability
|
|
171
|
+
lower = math.floor(index)
|
|
172
|
+
upper = math.ceil(index)
|
|
173
|
+
if lower == upper:
|
|
174
|
+
return values[lower]
|
|
175
|
+
return values[lower] + (values[upper] - values[lower]) * (index - lower)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _dump_sorted(
|
|
179
|
+
values: list[tuple[tuple[object, ...], ServingEndpointSummary]],
|
|
180
|
+
) -> list[dict[str, object]]:
|
|
181
|
+
return [endpoint.as_dict() for _, endpoint in sorted(values, key=lambda value: value[0])]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def extract_embedding(response: object, expected_dimension: int | None = None) -> list[float]:
|
|
7
|
+
if not isinstance(response, dict):
|
|
8
|
+
raise TypeError("Embedding response must be an object")
|
|
9
|
+
data = response.get("data")
|
|
10
|
+
if not isinstance(data, list) or not data or not isinstance(data[0], dict):
|
|
11
|
+
raise ValueError("Embedding response contains no data")
|
|
12
|
+
vector = data[0].get("embedding")
|
|
13
|
+
if not isinstance(vector, list) or not all(isinstance(value, (int, float)) for value in vector):
|
|
14
|
+
raise ValueError("Embedding response contains no numeric vector")
|
|
15
|
+
result = [float(value) for value in vector]
|
|
16
|
+
if expected_dimension is not None and len(result) != expected_dimension:
|
|
17
|
+
raise ValueError(
|
|
18
|
+
f"Expected embedding dimension {expected_dimension}, received {len(result)}"
|
|
19
|
+
)
|
|
20
|
+
return result
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def extract_embeddings(
|
|
24
|
+
response: object, expected_dimension: int | None = None
|
|
25
|
+
) -> list[list[float]]:
|
|
26
|
+
if not isinstance(response, dict) or not isinstance(response.get("data"), list):
|
|
27
|
+
raise TypeError("Embedding response contains no data")
|
|
28
|
+
return [
|
|
29
|
+
extract_embedding({"data": [item]}, expected_dimension)
|
|
30
|
+
for item in response["data"]
|
|
31
|
+
if isinstance(item, dict)
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def embedding_dimension(vector: Iterable[float]) -> int:
|
|
36
|
+
return len(list(vector))
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .classify import classify_by_family
|
|
4
|
+
from .models import ModelClass
|
|
5
|
+
|
|
6
|
+
_FALLBACK_MODEL_NAMES = (
|
|
7
|
+
"databricks-claude-opus-4-8",
|
|
8
|
+
"databricks-gpt-5-5-pro",
|
|
9
|
+
"databricks-gemini-3-1-pro",
|
|
10
|
+
"databricks-claude-sonnet-4-6",
|
|
11
|
+
"databricks-gpt-5-5",
|
|
12
|
+
"databricks-meta-llama-3-3-70b-instruct",
|
|
13
|
+
"databricks-claude-haiku-4-5",
|
|
14
|
+
"databricks-gpt-5-nano",
|
|
15
|
+
"databricks-meta-llama-3-1-8b-instruct",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def models_for_class(model_class: ModelClass) -> list[str]:
|
|
20
|
+
classified = []
|
|
21
|
+
for name in _FALLBACK_MODEL_NAMES:
|
|
22
|
+
family = classify_by_family(name)
|
|
23
|
+
if family is not None and family["class"] == model_class.value:
|
|
24
|
+
classified.append((int(family["rank"]), name))
|
|
25
|
+
return [name for _, name in sorted(classified, reverse=True)]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def model_for_class(model_class: ModelClass) -> str:
|
|
29
|
+
return models_for_class(model_class)[0]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
FALLBACK_MODEL_IDS = tuple(
|
|
33
|
+
model
|
|
34
|
+
for model_class in (
|
|
35
|
+
ModelClass.CHAT_THINKING,
|
|
36
|
+
ModelClass.CHAT_BALANCED,
|
|
37
|
+
ModelClass.CHAT_FAST,
|
|
38
|
+
)
|
|
39
|
+
for model in models_for_class(model_class)
|
|
40
|
+
)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
|
+
from typing import Any, Protocol
|
|
7
|
+
from urllib.parse import quote, urljoin
|
|
8
|
+
from urllib.request import Request, urlopen
|
|
9
|
+
|
|
10
|
+
INVOCATIONS_SUFFIX = "invocations"
|
|
11
|
+
RESPONSES_PATH = "serving-endpoints/responses"
|
|
12
|
+
OPEN_RESPONSES_PATH = "serving-endpoints/open-responses"
|
|
13
|
+
CHAT_COMPLETIONS_PATH = "serving-endpoints/chat/completions"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AuthenticatingConfig(Protocol):
|
|
17
|
+
def authenticate(self) -> Mapping[str, str]: ...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AuthenticatingClientLike(Protocol):
|
|
21
|
+
config: AuthenticatingConfig
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def invocations_url(host: str, endpoint: str) -> str:
|
|
25
|
+
return urljoin(host, f"serving-endpoints/{quote(endpoint, safe='')}/{INVOCATIONS_SUFFIX}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def responses_url(host: str) -> str:
|
|
29
|
+
return urljoin(host, RESPONSES_PATH)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def open_responses_url(host: str) -> str:
|
|
33
|
+
return urljoin(host, OPEN_RESPONSES_PATH)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def chat_completions_url(host: str) -> str:
|
|
37
|
+
return urljoin(host, CHAT_COMPLETIONS_PATH)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_responses_only(endpoint: str) -> bool:
|
|
41
|
+
return "codex" in endpoint.lower()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def responses_upstream_url(host: str, endpoint: str) -> str:
|
|
45
|
+
normalized = endpoint.lower()
|
|
46
|
+
openai_family = (
|
|
47
|
+
"gpt" in normalized
|
|
48
|
+
or "codex" in normalized
|
|
49
|
+
or re.search(r"(^|[^a-z])o[1-9]([^a-z]|$)", normalized) is not None
|
|
50
|
+
or "openai" in normalized
|
|
51
|
+
)
|
|
52
|
+
return responses_url(host) if openai_family else open_responses_url(host)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def auth_headers(client: AuthenticatingClientLike) -> dict[str, str]:
|
|
56
|
+
return dict(client.config.authenticate())
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def post_json(
|
|
60
|
+
client: AuthenticatingClientLike,
|
|
61
|
+
url: str,
|
|
62
|
+
body: Mapping[str, object],
|
|
63
|
+
*,
|
|
64
|
+
timeout: float | None = None,
|
|
65
|
+
opener: Callable[..., Any] = urlopen,
|
|
66
|
+
) -> object:
|
|
67
|
+
headers = {"Accept": "application/json", "Content-Type": "application/json"}
|
|
68
|
+
headers.update(auth_headers(client))
|
|
69
|
+
request = Request(url, data=json.dumps(body).encode(), headers=headers, method="POST")
|
|
70
|
+
with opener(request, timeout=timeout) as response:
|
|
71
|
+
return json.loads(response.read())
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def invoke_json(
|
|
75
|
+
client: AuthenticatingClientLike,
|
|
76
|
+
host: str,
|
|
77
|
+
endpoint: str,
|
|
78
|
+
body: Mapping[str, object],
|
|
79
|
+
**options: object,
|
|
80
|
+
) -> object:
|
|
81
|
+
return post_json(client, invocations_url(host, endpoint), body, **options)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ModelClass(str, Enum):
|
|
9
|
+
CHAT_THINKING = "chat-thinking"
|
|
10
|
+
CHAT_BALANCED = "chat-balanced"
|
|
11
|
+
CHAT_FAST = "chat-fast"
|
|
12
|
+
EMBEDDING = "embedding"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class WireModel(BaseModel):
|
|
16
|
+
model_config = ConfigDict(populate_by_name=True, extra="ignore")
|
|
17
|
+
|
|
18
|
+
def as_dict(self) -> dict[str, object]:
|
|
19
|
+
return self.model_dump(by_alias=True, exclude_none=True, mode="json")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ModelProfile(WireModel):
|
|
23
|
+
quality: float | None = None
|
|
24
|
+
speed: float | None = None
|
|
25
|
+
cost: float | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ServingEndpointSummary(WireModel):
|
|
29
|
+
name: str
|
|
30
|
+
display_name: str | None = Field(default=None, alias="displayName")
|
|
31
|
+
task: str | None = None
|
|
32
|
+
state: str | None = None
|
|
33
|
+
description: str | None = None
|
|
34
|
+
supports_tools: bool | None = Field(default=None, alias="supportsTools")
|
|
35
|
+
profile: ModelProfile | None = None
|
|
36
|
+
model_class: ModelClass | None = Field(default=None, alias="class")
|
|
37
|
+
dimension: int | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ModelQuery(WireModel):
|
|
41
|
+
search: str | None = None
|
|
42
|
+
model_class: ModelClass | None = Field(default=None, alias="modelClass")
|
|
43
|
+
requires_tools: bool | None = Field(default=None, alias="requiresTools")
|
|
44
|
+
limit: int | None = None
|
|
45
|
+
threshold: float | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RankedModel(WireModel):
|
|
49
|
+
endpoint: ServingEndpointSummary
|
|
50
|
+
model_class: ModelClass = Field(alias="modelClass")
|
|
51
|
+
score: float | None = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ResolvedModel(WireModel):
|
|
55
|
+
model_id: str = Field(alias="modelId")
|
|
56
|
+
matched: bool
|
|
57
|
+
score: float | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ResolvedModelSelection(WireModel):
|
|
61
|
+
model_id: str = Field(alias="modelId")
|
|
62
|
+
source: str
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class EndpointCapabilities(WireModel):
|
|
66
|
+
chat: bool
|
|
67
|
+
embedding: bool
|
|
68
|
+
tools: bool
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Iterable, Sequence
|
|
5
|
+
from difflib import SequenceMatcher
|
|
6
|
+
|
|
7
|
+
from .classes import CHAT_CLASS_ORDER, MODEL_CLASS_ORDER, classes_at_or_below
|
|
8
|
+
from .classify import classified_summaries, endpoint_capabilities
|
|
9
|
+
from .fallback import FALLBACK_MODEL_IDS, models_for_class
|
|
10
|
+
from .models import (
|
|
11
|
+
ModelClass,
|
|
12
|
+
ModelQuery,
|
|
13
|
+
RankedModel,
|
|
14
|
+
ResolvedModel,
|
|
15
|
+
ResolvedModelSelection,
|
|
16
|
+
ServingEndpointSummary,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
DEFAULT_FUZZY_THRESHOLD = 0.4
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def search_serving_endpoints(
|
|
23
|
+
input_value: str,
|
|
24
|
+
endpoints: Iterable[ServingEndpointSummary | dict[str, object]],
|
|
25
|
+
threshold: float = DEFAULT_FUZZY_THRESHOLD,
|
|
26
|
+
) -> list[dict[str, object]]:
|
|
27
|
+
summaries = [_summary(endpoint) for endpoint in endpoints]
|
|
28
|
+
for endpoint in summaries:
|
|
29
|
+
if endpoint.name == input_value:
|
|
30
|
+
return [{"endpoint": endpoint.as_dict(), "score": 0}]
|
|
31
|
+
tokens = re.findall(r"[a-zA-Z0-9]+", input_value.lower())
|
|
32
|
+
if not tokens:
|
|
33
|
+
return []
|
|
34
|
+
matches = []
|
|
35
|
+
for endpoint in summaries:
|
|
36
|
+
name = endpoint.name.lower()
|
|
37
|
+
token_scores = [_token_distance(token, name) for token in tokens]
|
|
38
|
+
score = sum(token_scores) / len(token_scores)
|
|
39
|
+
if score <= threshold:
|
|
40
|
+
matches.append({"endpoint": endpoint.as_dict(), "score": score})
|
|
41
|
+
return sorted(matches, key=lambda match: (float(match["score"]), match["endpoint"]["name"]))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def resolve_model_id(
|
|
45
|
+
input_value: str,
|
|
46
|
+
endpoints: Iterable[ServingEndpointSummary | dict[str, object]],
|
|
47
|
+
*,
|
|
48
|
+
threshold: float = DEFAULT_FUZZY_THRESHOLD,
|
|
49
|
+
requires_tools: bool = False,
|
|
50
|
+
) -> ResolvedModel:
|
|
51
|
+
candidates = [
|
|
52
|
+
_summary(endpoint)
|
|
53
|
+
for endpoint in endpoints
|
|
54
|
+
if not requires_tools or endpoint_capabilities(_summary(endpoint)).tools
|
|
55
|
+
]
|
|
56
|
+
matches = search_serving_endpoints(input_value, candidates, threshold)
|
|
57
|
+
if not matches:
|
|
58
|
+
return ResolvedModel(modelId=input_value, matched=False)
|
|
59
|
+
top = matches[0]
|
|
60
|
+
return ResolvedModel(
|
|
61
|
+
modelId=str(top["endpoint"]["name"]), matched=True, score=float(top["score"])
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def rank_models(
|
|
66
|
+
endpoints: Iterable[ServingEndpointSummary | dict[str, object]],
|
|
67
|
+
query: ModelQuery | dict[str, object] | None = None,
|
|
68
|
+
) -> list[dict[str, object]]:
|
|
69
|
+
summaries = [_summary(endpoint) for endpoint in endpoints]
|
|
70
|
+
request = query if isinstance(query, ModelQuery) else ModelQuery.model_validate(query or {})
|
|
71
|
+
classified = classified_summaries(summaries)
|
|
72
|
+
eligible = (
|
|
73
|
+
classes_at_or_below(request.model_class)
|
|
74
|
+
if request.model_class is not None
|
|
75
|
+
else list(CHAT_CLASS_ORDER)
|
|
76
|
+
)
|
|
77
|
+
candidates: list[RankedModel] = []
|
|
78
|
+
for model_class in eligible:
|
|
79
|
+
for endpoint in classified[model_class]:
|
|
80
|
+
if request.requires_tools and not endpoint_capabilities(endpoint).tools:
|
|
81
|
+
continue
|
|
82
|
+
candidates.append(RankedModel(endpoint=endpoint, modelClass=model_class))
|
|
83
|
+
|
|
84
|
+
search = request.search.strip() if request.search else ""
|
|
85
|
+
if search:
|
|
86
|
+
scores = {
|
|
87
|
+
str(match["endpoint"]["name"]): float(match["score"])
|
|
88
|
+
for match in search_serving_endpoints(
|
|
89
|
+
search,
|
|
90
|
+
[candidate.endpoint for candidate in candidates],
|
|
91
|
+
request.threshold if request.threshold is not None else DEFAULT_FUZZY_THRESHOLD,
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
candidates = [candidate for candidate in candidates if candidate.endpoint.name in scores]
|
|
95
|
+
for candidate in candidates:
|
|
96
|
+
candidate.score = scores[candidate.endpoint.name]
|
|
97
|
+
candidates.sort(
|
|
98
|
+
key=lambda candidate: (
|
|
99
|
+
round((candidate.score or 0) * 1000),
|
|
100
|
+
MODEL_CLASS_ORDER.index(candidate.model_class),
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
if request.limit is not None:
|
|
104
|
+
candidates = candidates[: max(0, request.limit)]
|
|
105
|
+
return [candidate.as_dict() for candidate in candidates]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def rank_model_id(
|
|
109
|
+
endpoints: Iterable[ServingEndpointSummary | dict[str, object]],
|
|
110
|
+
search: str,
|
|
111
|
+
*,
|
|
112
|
+
threshold: float = DEFAULT_FUZZY_THRESHOLD,
|
|
113
|
+
requires_tools: bool = False,
|
|
114
|
+
) -> ResolvedModel:
|
|
115
|
+
ranked = rank_models(
|
|
116
|
+
endpoints,
|
|
117
|
+
ModelQuery(
|
|
118
|
+
search=search,
|
|
119
|
+
limit=1,
|
|
120
|
+
threshold=threshold,
|
|
121
|
+
requiresTools=requires_tools,
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
if not ranked:
|
|
125
|
+
return ResolvedModel(modelId=search, matched=False)
|
|
126
|
+
return ResolvedModel(
|
|
127
|
+
modelId=str(ranked[0]["endpoint"]["name"]),
|
|
128
|
+
matched=True,
|
|
129
|
+
score=float(ranked[0]["score"]),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def resolve_model(
|
|
134
|
+
endpoints: Iterable[ServingEndpointSummary | dict[str, object]],
|
|
135
|
+
*,
|
|
136
|
+
explicit: str | None = None,
|
|
137
|
+
fuzzy: bool = True,
|
|
138
|
+
threshold: float = DEFAULT_FUZZY_THRESHOLD,
|
|
139
|
+
requires_tools: bool = False,
|
|
140
|
+
model_class: ModelClass | None = None,
|
|
141
|
+
fallbacks: Sequence[str] = (),
|
|
142
|
+
) -> ResolvedModelSelection:
|
|
143
|
+
summaries = [_summary(endpoint) for endpoint in endpoints]
|
|
144
|
+
if explicit is not None:
|
|
145
|
+
if not fuzzy:
|
|
146
|
+
if requires_tools:
|
|
147
|
+
_assert_tool_support(summaries, explicit)
|
|
148
|
+
return ResolvedModelSelection(modelId=explicit, source="explicit")
|
|
149
|
+
ranked = rank_models(
|
|
150
|
+
summaries,
|
|
151
|
+
ModelQuery(
|
|
152
|
+
search=explicit,
|
|
153
|
+
modelClass=model_class,
|
|
154
|
+
requiresTools=requires_tools,
|
|
155
|
+
threshold=threshold,
|
|
156
|
+
limit=1,
|
|
157
|
+
),
|
|
158
|
+
)
|
|
159
|
+
if requires_tools and not ranked:
|
|
160
|
+
raise ValueError(f'No tool-capable model matches "{explicit}"')
|
|
161
|
+
model_id = str(ranked[0]["endpoint"]["name"]) if ranked else explicit
|
|
162
|
+
return ResolvedModelSelection(modelId=model_id, source="fuzzy-match")
|
|
163
|
+
|
|
164
|
+
if model_class is None and fallbacks:
|
|
165
|
+
present = {
|
|
166
|
+
endpoint.name
|
|
167
|
+
for endpoint in summaries
|
|
168
|
+
if not requires_tools or endpoint_capabilities(endpoint).tools
|
|
169
|
+
}
|
|
170
|
+
for fallback in fallbacks:
|
|
171
|
+
if fallback in present:
|
|
172
|
+
return ResolvedModelSelection(modelId=fallback, source="fallback")
|
|
173
|
+
|
|
174
|
+
source = "class" if model_class is not None else "fallback"
|
|
175
|
+
ranked = rank_models(
|
|
176
|
+
summaries,
|
|
177
|
+
ModelQuery(modelClass=model_class, requiresTools=requires_tools, limit=1),
|
|
178
|
+
)
|
|
179
|
+
if ranked:
|
|
180
|
+
return ResolvedModelSelection(modelId=str(ranked[0]["endpoint"]["name"]), source=source)
|
|
181
|
+
|
|
182
|
+
floor_source = models_for_class(model_class) if model_class is not None else list(fallbacks)
|
|
183
|
+
floor = list(dict.fromkeys([*floor_source, *FALLBACK_MODEL_IDS]))
|
|
184
|
+
if requires_tools:
|
|
185
|
+
available = {
|
|
186
|
+
endpoint.name for endpoint in summaries if endpoint_capabilities(endpoint).tools
|
|
187
|
+
}
|
|
188
|
+
selected = next((model_id for model_id in floor if model_id in available), None)
|
|
189
|
+
if selected is None:
|
|
190
|
+
raise ValueError("No tool-capable model is available")
|
|
191
|
+
return ResolvedModelSelection(modelId=selected, source=source)
|
|
192
|
+
present = {endpoint.name for endpoint in summaries}
|
|
193
|
+
selected = next((model_id for model_id in floor if model_id in present), floor[0])
|
|
194
|
+
return ResolvedModelSelection(modelId=selected, source=source)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _token_distance(token: str, name: str) -> float:
|
|
198
|
+
if token in name:
|
|
199
|
+
return 0.0
|
|
200
|
+
segments = re.findall(r"[a-z0-9]+", name)
|
|
201
|
+
similarity = max(
|
|
202
|
+
(SequenceMatcher(None, token, segment).ratio() for segment in segments), default=0
|
|
203
|
+
)
|
|
204
|
+
return 1 - similarity
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _assert_tool_support(endpoints: list[ServingEndpointSummary], model_id: str) -> None:
|
|
208
|
+
endpoint = next((candidate for candidate in endpoints if candidate.name == model_id), None)
|
|
209
|
+
if endpoint is None or not endpoint_capabilities(endpoint).tools:
|
|
210
|
+
raise ValueError(f'Model "{model_id}" does not support function tools')
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _summary(value: ServingEndpointSummary | dict[str, object]) -> ServingEndpointSummary:
|
|
214
|
+
return (
|
|
215
|
+
value
|
|
216
|
+
if isinstance(value, ServingEndpointSummary)
|
|
217
|
+
else ServingEndpointSummary.model_validate(value)
|
|
218
|
+
)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Iterable, Mapping
|
|
6
|
+
from typing import Any, Protocol
|
|
7
|
+
|
|
8
|
+
from .classes import MODEL_CLASS_ORDER
|
|
9
|
+
from .classify import classified_summaries, supports_tools_by_family
|
|
10
|
+
from .models import ModelProfile, ServingEndpointSummary
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ServingEndpointsApi(Protocol):
|
|
14
|
+
def list(self) -> Iterable[object]: ...
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class WorkspaceClientLike(Protocol):
|
|
18
|
+
serving_endpoints: ServingEndpointsApi
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def list_serving_endpoints(client: WorkspaceClientLike) -> list[ServingEndpointSummary]:
|
|
22
|
+
summaries = list_serving_endpoints_uncached(client)
|
|
23
|
+
buckets = classified_summaries(summaries)
|
|
24
|
+
classes = {
|
|
25
|
+
endpoint.name: model_class
|
|
26
|
+
for model_class in MODEL_CLASS_ORDER
|
|
27
|
+
for endpoint in buckets[model_class]
|
|
28
|
+
}
|
|
29
|
+
for summary in summaries:
|
|
30
|
+
summary.model_class = classes.get(summary.name)
|
|
31
|
+
return summaries
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def list_serving_endpoints_uncached(client: WorkspaceClientLike) -> list[ServingEndpointSummary]:
|
|
35
|
+
summaries = []
|
|
36
|
+
for endpoint in client.serving_endpoints.list():
|
|
37
|
+
name = _value(endpoint, "name")
|
|
38
|
+
if not isinstance(name, str) or not name:
|
|
39
|
+
continue
|
|
40
|
+
summaries.append(
|
|
41
|
+
ServingEndpointSummary(
|
|
42
|
+
name=name,
|
|
43
|
+
displayName=to_model_display_name(name, _provided_display_name(endpoint)),
|
|
44
|
+
task=_string(_value(endpoint, "task")),
|
|
45
|
+
state=_string(_value(_value(endpoint, "state"), "ready")),
|
|
46
|
+
description=_string(_value(endpoint, "description")),
|
|
47
|
+
supportsTools=supports_tools_by_family(name),
|
|
48
|
+
profile=_extract_profile(endpoint),
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
return summaries
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def to_model_display_name(name: str, provided: str | None = None) -> str:
|
|
55
|
+
if provided is not None and provided.strip():
|
|
56
|
+
return provided.strip()
|
|
57
|
+
segments = [segment for segment in re.split(r"[-_.\s/]+", name) if segment]
|
|
58
|
+
start = 0
|
|
59
|
+
while start < len(segments) and segments[start].lower() in {"databricks", "system", "dbx"}:
|
|
60
|
+
if (
|
|
61
|
+
segments[start].lower() == "system"
|
|
62
|
+
and start + 1 < len(segments)
|
|
63
|
+
and segments[start + 1].lower() == "ai"
|
|
64
|
+
):
|
|
65
|
+
start += 1
|
|
66
|
+
start += 1
|
|
67
|
+
source = segments[start:] or segments
|
|
68
|
+
acronyms = {"gpt", "gte", "bge", "dbrx", "oss", "llm", "moe", "ai"}
|
|
69
|
+
pieces = []
|
|
70
|
+
numeric = []
|
|
71
|
+
for segment in source:
|
|
72
|
+
if segment.isdigit():
|
|
73
|
+
numeric.append(segment)
|
|
74
|
+
continue
|
|
75
|
+
if numeric:
|
|
76
|
+
pieces.append(".".join(numeric))
|
|
77
|
+
numeric = []
|
|
78
|
+
size = re.fullmatch(r"(\d+)([bmk])", segment, flags=re.IGNORECASE)
|
|
79
|
+
if size:
|
|
80
|
+
pieces.append(f"{size.group(1)}{size.group(2).upper()}")
|
|
81
|
+
else:
|
|
82
|
+
lower = segment.lower()
|
|
83
|
+
pieces.append(lower.upper() if lower in acronyms else lower.capitalize())
|
|
84
|
+
if numeric:
|
|
85
|
+
pieces.append(".".join(numeric))
|
|
86
|
+
return " ".join(pieces) or name.strip()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _extract_profile(endpoint: object) -> ModelProfile | None:
|
|
90
|
+
config = _value(endpoint, "config")
|
|
91
|
+
entities = _value(config, "served_entities") or []
|
|
92
|
+
for entity in entities:
|
|
93
|
+
foundation = _value(entity, "foundation_model")
|
|
94
|
+
raw = _value(foundation, "ai_gateway_model_profile")
|
|
95
|
+
values = {
|
|
96
|
+
key: value
|
|
97
|
+
for key in ("quality", "speed", "cost")
|
|
98
|
+
if isinstance((value := _value(raw, key)), (int, float)) and math.isfinite(value)
|
|
99
|
+
}
|
|
100
|
+
if values:
|
|
101
|
+
return ModelProfile.model_validate(values)
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _provided_display_name(endpoint: object) -> str | None:
|
|
106
|
+
for tag in _value(endpoint, "tags") or []:
|
|
107
|
+
if _value(tag, "key") in {"display_name", "displayName", "name"}:
|
|
108
|
+
value = _string(_value(tag, "value"))
|
|
109
|
+
if value and value.strip():
|
|
110
|
+
return value.strip()
|
|
111
|
+
config = _value(endpoint, "config")
|
|
112
|
+
for entity in _value(config, "served_entities") or []:
|
|
113
|
+
value = _string(_value(_value(entity, "external_model"), "name"))
|
|
114
|
+
if value and value.strip():
|
|
115
|
+
return value.strip()
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _value(value: object, key: str) -> Any:
|
|
120
|
+
if isinstance(value, Mapping):
|
|
121
|
+
return value.get(key)
|
|
122
|
+
return getattr(value, key, None)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _string(value: object) -> str | None:
|
|
126
|
+
return None if value is None else str(value)
|