symphony-core 0.0.1__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.
- symphony_core-0.0.1/.gitignore +7 -0
- symphony_core-0.0.1/LICENSE +21 -0
- symphony_core-0.0.1/PKG-INFO +53 -0
- symphony_core-0.0.1/README.md +37 -0
- symphony_core-0.0.1/hatch_build.py +54 -0
- symphony_core-0.0.1/pyproject.toml +51 -0
- symphony_core-0.0.1/scripts/generate_models.py +382 -0
- symphony_core-0.0.1/src/core_ai/__init__.py +81 -0
- symphony_core-0.0.1/src/core_ai/content.py +384 -0
- symphony_core-0.0.1/src/core_ai/models/__init__.py +18 -0
- symphony_core-0.0.1/src/core_ai/models/generated.py +110 -0
- symphony_core-0.0.1/src/core_ai/models/registry.py +53 -0
- symphony_core-0.0.1/src/core_ai/models/types.py +20 -0
- symphony_core-0.0.1/src/core_ai/oauth/__init__.py +31 -0
- symphony_core-0.0.1/src/core_ai/oauth/anthropic.py +145 -0
- symphony_core-0.0.1/src/core_ai/oauth/callback.py +56 -0
- symphony_core-0.0.1/src/core_ai/oauth/login.py +242 -0
- symphony_core-0.0.1/src/core_ai/oauth/openai_codex.py +199 -0
- symphony_core-0.0.1/src/core_ai/oauth/pkce.py +70 -0
- symphony_core-0.0.1/src/core_ai/oauth/runtime.py +89 -0
- symphony_core-0.0.1/src/core_ai/oauth/store.py +121 -0
- symphony_core-0.0.1/src/core_ai/oauth/types.py +75 -0
- symphony_core-0.0.1/src/core_ai/oauth/xai.py +187 -0
- symphony_core-0.0.1/src/core_ai/providers/__init__.py +0 -0
- symphony_core-0.0.1/src/core_ai/providers/anthropic.py +296 -0
- symphony_core-0.0.1/src/core_ai/providers/base.py +36 -0
- symphony_core-0.0.1/src/core_ai/providers/catalog.py +241 -0
- symphony_core-0.0.1/src/core_ai/providers/defaults.py +289 -0
- symphony_core-0.0.1/src/core_ai/providers/gemini.py +327 -0
- symphony_core-0.0.1/src/core_ai/providers/grok.py +58 -0
- symphony_core-0.0.1/src/core_ai/providers/http.py +312 -0
- symphony_core-0.0.1/src/core_ai/providers/local.py +82 -0
- symphony_core-0.0.1/src/core_ai/providers/ollama.py +141 -0
- symphony_core-0.0.1/src/core_ai/providers/openai.py +570 -0
- symphony_core-0.0.1/src/core_ai/providers/openrouter.py +99 -0
- symphony_core-0.0.1/src/core_ai/providers/vercel.py +195 -0
- symphony_core-0.0.1/src/core_ai/providers/vercel_evaluation.py +288 -0
- symphony_core-0.0.1/src/core_ai/py.typed +0 -0
- symphony_core-0.0.1/src/core_ai/registry.py +97 -0
- symphony_core-0.0.1/src/core_ai/types.py +45 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sarthak Chauhan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: symphony-core
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Symphony provider layer: model registry, generated model catalog, and streaming message types (import as core_ai)
|
|
5
|
+
Project-URL: Homepage, https://github.com/Sarthakischauhan/symphony
|
|
6
|
+
Project-URL: Documentation, https://github.com/Sarthakischauhan/symphony/blob/main/docs/packages/symphony-core.md
|
|
7
|
+
Project-URL: Source, https://github.com/Sarthakischauhan/symphony/tree/main/core_ai
|
|
8
|
+
Author-email: Sarthak Chauhan <sarthakchauhann@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Requires-Dist: dotenv>=0.9.9
|
|
13
|
+
Requires-Dist: httpx>=0.28.1
|
|
14
|
+
Requires-Dist: pydantic>=2.13.4
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# symphony-core
|
|
18
|
+
|
|
19
|
+
Provider integrations and model-neutral streaming types for Symphony agents. Python import: `core_ai`.
|
|
20
|
+
|
|
21
|
+
**Availability:** this package is not currently published on PyPI. Use it from the repository's uv workspace and follow the [source installation guide](../docs/getting-started/installation.md).
|
|
22
|
+
|
|
23
|
+
## Minimal example
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from core_ai import build_default_registry, default_model_id
|
|
27
|
+
|
|
28
|
+
registry = build_default_registry()
|
|
29
|
+
model_id = default_model_id(registry)
|
|
30
|
+
print(model_id) # provider:model
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The registry includes supported cloud providers for which credentials are configured. Ollama and generic local OpenAI-compatible providers are opt-in. See the [provider/environment reference](../docs/reference/environment.md).
|
|
34
|
+
|
|
35
|
+
## What the package provides
|
|
36
|
+
|
|
37
|
+
- Provider adapters for OpenAI, Anthropic, Gemini, Grok, OpenRouter, Vercel AI Gateway, Ollama, and compatible local endpoints.
|
|
38
|
+
- `ModelRegistry` and provider-prefixed model IDs (`provider:model`).
|
|
39
|
+
- Provider-independent `Message` and `StreamEvent` types, including streamed text and tool calls.
|
|
40
|
+
- A checked-in generated catalog of supported tool-calling models; ordinary installation/build does not refresh it.
|
|
41
|
+
|
|
42
|
+
For public interfaces and streaming behavior, see the [symphony-core guide](../docs/packages/symphony-core.md). To build an agent that uses these providers, continue to [symphony-harness](../docs/packages/symphony-harness.md).
|
|
43
|
+
|
|
44
|
+
## Maintainer: refresh the checked-in catalog
|
|
45
|
+
|
|
46
|
+
The generated model list is a snapshot; building the workspace never updates it. To deliberately refresh and commit the catalog:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
cd core_ai
|
|
50
|
+
uv run python scripts/generate_models.py
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The script reads models.dev. When that source is unavailable, it keeps the existing snapshot unless `CORE_AI_GENERATE_STRICT=1` is set. See [`scripts/generate_models.py`](scripts/generate_models.py) for maintainer options.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# symphony-core
|
|
2
|
+
|
|
3
|
+
Provider integrations and model-neutral streaming types for Symphony agents. Python import: `core_ai`.
|
|
4
|
+
|
|
5
|
+
**Availability:** this package is not currently published on PyPI. Use it from the repository's uv workspace and follow the [source installation guide](../docs/getting-started/installation.md).
|
|
6
|
+
|
|
7
|
+
## Minimal example
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from core_ai import build_default_registry, default_model_id
|
|
11
|
+
|
|
12
|
+
registry = build_default_registry()
|
|
13
|
+
model_id = default_model_id(registry)
|
|
14
|
+
print(model_id) # provider:model
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The registry includes supported cloud providers for which credentials are configured. Ollama and generic local OpenAI-compatible providers are opt-in. See the [provider/environment reference](../docs/reference/environment.md).
|
|
18
|
+
|
|
19
|
+
## What the package provides
|
|
20
|
+
|
|
21
|
+
- Provider adapters for OpenAI, Anthropic, Gemini, Grok, OpenRouter, Vercel AI Gateway, Ollama, and compatible local endpoints.
|
|
22
|
+
- `ModelRegistry` and provider-prefixed model IDs (`provider:model`).
|
|
23
|
+
- Provider-independent `Message` and `StreamEvent` types, including streamed text and tool calls.
|
|
24
|
+
- A checked-in generated catalog of supported tool-calling models; ordinary installation/build does not refresh it.
|
|
25
|
+
|
|
26
|
+
For public interfaces and streaming behavior, see the [symphony-core guide](../docs/packages/symphony-core.md). To build an agent that uses these providers, continue to [symphony-harness](../docs/packages/symphony-harness.md).
|
|
27
|
+
|
|
28
|
+
## Maintainer: refresh the checked-in catalog
|
|
29
|
+
|
|
30
|
+
The generated model list is a snapshot; building the workspace never updates it. To deliberately refresh and commit the catalog:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
cd core_ai
|
|
34
|
+
uv run python scripts/generate_models.py
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The script reads models.dev. When that source is unavailable, it keeps the existing snapshot unless `CORE_AI_GENERATE_STRICT=1` is set. See [`scripts/generate_models.py`](scripts/generate_models.py) for maintainer options.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Hatch build hook for symphony-core.
|
|
2
|
+
|
|
3
|
+
The model catalog in ``src/core_ai/models/generated.py`` is a checked-in
|
|
4
|
+
snapshot and ships as-is. Building or installing the package never touches
|
|
5
|
+
the network and never rewrites the snapshot.
|
|
6
|
+
|
|
7
|
+
Maintainers refresh the snapshot explicitly, either by running
|
|
8
|
+
``uv run python scripts/generate_models.py`` and committing the result, or by
|
|
9
|
+
setting ``CORE_AI_REFRESH_CATALOG=1`` for a single build. In that opt-in mode
|
|
10
|
+
a failed refresh fails the build instead of silently keeping stale data.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
|
20
|
+
|
|
21
|
+
REFRESH_ENV = "CORE_AI_REFRESH_CATALOG"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def refresh_requested(environ: "os._Environ[str] | dict[str, str]" = os.environ) -> bool:
|
|
25
|
+
return environ.get(REFRESH_ENV, "").strip().lower() in {"1", "true", "yes"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def refresh_catalog(root: Path) -> Path:
|
|
29
|
+
"""Regenerate the snapshot from models.dev. Raises on any failure."""
|
|
30
|
+
try:
|
|
31
|
+
import httpx # noqa: F401 # only needed for the opt-in refresh
|
|
32
|
+
except ImportError as exc:
|
|
33
|
+
raise RuntimeError(
|
|
34
|
+
f"{REFRESH_ENV} needs httpx in the build environment; run "
|
|
35
|
+
"`uv run python scripts/generate_models.py` instead, or build with "
|
|
36
|
+
"`--no-build-isolation` in an environment that has httpx."
|
|
37
|
+
) from exc
|
|
38
|
+
scripts = root / "scripts"
|
|
39
|
+
sys.path.insert(0, str(scripts))
|
|
40
|
+
try:
|
|
41
|
+
from generate_models import generate
|
|
42
|
+
finally:
|
|
43
|
+
sys.path.remove(str(scripts))
|
|
44
|
+
return generate(strict=True)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CustomBuildHook(BuildHookInterface):
|
|
48
|
+
"""Ship the checked-in catalog; refresh only when explicitly asked."""
|
|
49
|
+
|
|
50
|
+
def initialize(self, version: str, build_data: dict) -> None:
|
|
51
|
+
if not refresh_requested():
|
|
52
|
+
return
|
|
53
|
+
self.app.display_info(f"{REFRESH_ENV} set: refreshing model catalog from models.dev")
|
|
54
|
+
refresh_catalog(Path(self.root))
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "symphony-core"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "Symphony provider layer: model registry, generated model catalog, and streaming message types (import as core_ai)"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Sarthak Chauhan", email = "sarthakchauhann@gmail.com" }
|
|
10
|
+
]
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"dotenv>=0.9.9",
|
|
14
|
+
"httpx>=0.28.1",
|
|
15
|
+
"pydantic>=2.13.4",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
Homepage = "https://github.com/Sarthakischauhan/symphony"
|
|
20
|
+
Documentation = "https://github.com/Sarthakischauhan/symphony/blob/main/docs/packages/symphony-core.md"
|
|
21
|
+
Source = "https://github.com/Sarthakischauhan/symphony/tree/main/core_ai"
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
# The build hook ships the checked-in catalog snapshot and does not touch the
|
|
25
|
+
# network unless CORE_AI_REFRESH_CATALOG=1 is set (see hatch_build.py).
|
|
26
|
+
requires = ["hatchling>=1.27"]
|
|
27
|
+
build-backend = "hatchling.build"
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.wheel]
|
|
30
|
+
packages = ["src/core_ai"]
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.wheel.sources]
|
|
33
|
+
"src" = ""
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.sdist]
|
|
36
|
+
include = [
|
|
37
|
+
"/src",
|
|
38
|
+
"/scripts",
|
|
39
|
+
"/hatch_build.py",
|
|
40
|
+
"/README.md",
|
|
41
|
+
"/LICENSE",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.hooks.custom]
|
|
45
|
+
path = "hatch_build.py"
|
|
46
|
+
|
|
47
|
+
[dependency-groups]
|
|
48
|
+
dev = [
|
|
49
|
+
"hatchling",
|
|
50
|
+
"pytest>=9.1.1",
|
|
51
|
+
]
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Generate the shipped core_ai model catalog.
|
|
3
|
+
|
|
4
|
+
This is a maintainer command, not part of installing the package:
|
|
5
|
+
|
|
6
|
+
uv run python scripts/generate_models.py
|
|
7
|
+
|
|
8
|
+
It downloads the curated models.dev catalog, projects it onto the providers
|
|
9
|
+
core_ai implements, and rewrites `src/core_ai/models/generated.py`. Commit
|
|
10
|
+
the result. If models.dev is unreachable the existing snapshot (or a small
|
|
11
|
+
curated fallback) is kept unless `CORE_AI_GENERATE_STRICT=1` is set.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Callable, Iterable
|
|
19
|
+
|
|
20
|
+
OUTPUT = Path(__file__).resolve().parents[1] / "src/core_ai/models/generated.py"
|
|
21
|
+
MODELS_DEV_URL = "https://models.dev/api.json"
|
|
22
|
+
MODELS_DEV_PROVIDERS = {
|
|
23
|
+
"openai": "openai",
|
|
24
|
+
"anthropic": "anthropic",
|
|
25
|
+
"gemini": "google",
|
|
26
|
+
"grok": "xai",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
# models.dev is already curated for model capability. These are the API
|
|
30
|
+
# families implemented by core_ai; unlike provider /models endpoints, the
|
|
31
|
+
# upstream catalog does not need credentials.
|
|
32
|
+
|
|
33
|
+
def models_dev_api(provider: str, model_id: str) -> str:
|
|
34
|
+
if provider == "openai" and model_id.startswith(OPENAI_CHAT_COMPLETIONS_PREFIXES):
|
|
35
|
+
return "chat_completions"
|
|
36
|
+
return {
|
|
37
|
+
"openai": "responses",
|
|
38
|
+
"anthropic": "messages",
|
|
39
|
+
"gemini": "generate_content",
|
|
40
|
+
"grok": "chat_completions",
|
|
41
|
+
}[provider]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def effort_map(options: object) -> tuple[tuple[str, str | None], ...]:
|
|
45
|
+
"""Normalize models.dev reasoning_options for the /effort UI."""
|
|
46
|
+
if not isinstance(options, list):
|
|
47
|
+
return ()
|
|
48
|
+
values = {
|
|
49
|
+
value
|
|
50
|
+
for option in options
|
|
51
|
+
if isinstance(option, dict) and option.get("type") == "effort"
|
|
52
|
+
for value in option.get("values", [])
|
|
53
|
+
if isinstance(value, str)
|
|
54
|
+
}
|
|
55
|
+
levels = ("off", "minimal", "low", "medium", "high", "xhigh", "max")
|
|
56
|
+
if not values or not (values.intersection(levels) or "none" in values):
|
|
57
|
+
return ()
|
|
58
|
+
return tuple((level, "none" if level == "off" and "none" in values else
|
|
59
|
+
level if level in values else None) for level in levels)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def fetch_models_dev_models(url: str = MODELS_DEV_URL) -> dict[str, list[tuple[str, str, bool, tuple[tuple[str, str | None], ...]]]]:
|
|
63
|
+
"""Load the curated models.dev catalog and project it to our providers.
|
|
64
|
+
|
|
65
|
+
The endpoint is one large JSON object, so filtering happens immediately
|
|
66
|
+
while iterating it. Only text-output models with tool calling support are
|
|
67
|
+
emitted; image, embedding, audio, and other entries never enter our
|
|
68
|
+
generated registry.
|
|
69
|
+
"""
|
|
70
|
+
import httpx
|
|
71
|
+
|
|
72
|
+
response = httpx.get(
|
|
73
|
+
url,
|
|
74
|
+
headers={"User-Agent": "core-ai-model-catalog/1.0"},
|
|
75
|
+
timeout=30.0,
|
|
76
|
+
)
|
|
77
|
+
response.raise_for_status()
|
|
78
|
+
payload = response.json()
|
|
79
|
+
if not isinstance(payload, dict):
|
|
80
|
+
raise ValueError("models.dev catalog must be a JSON object")
|
|
81
|
+
|
|
82
|
+
catalog = {provider: [] for provider in MODELS_DEV_PROVIDERS}
|
|
83
|
+
for provider_data in payload.values():
|
|
84
|
+
if not isinstance(provider_data, dict):
|
|
85
|
+
continue
|
|
86
|
+
upstream_provider = provider_data.get("id")
|
|
87
|
+
provider = next((name for name, upstream in MODELS_DEV_PROVIDERS.items() if upstream == upstream_provider), None)
|
|
88
|
+
models = provider_data.get("models")
|
|
89
|
+
if provider is None or not isinstance(models, dict):
|
|
90
|
+
continue
|
|
91
|
+
for model_id, metadata in models.items():
|
|
92
|
+
if not isinstance(model_id, str) or not isinstance(metadata, dict):
|
|
93
|
+
continue
|
|
94
|
+
modalities = metadata.get("modalities") or {}
|
|
95
|
+
output = modalities.get("output") or []
|
|
96
|
+
is_openai_image = provider == "openai" and model_id.startswith("gpt-image-")
|
|
97
|
+
if not is_openai_image and (metadata.get("tool_call") is not True or "text" not in output):
|
|
98
|
+
continue
|
|
99
|
+
limit = metadata.get("limit") or {}
|
|
100
|
+
context_limit = limit.get("context") if isinstance(limit, dict) else None
|
|
101
|
+
catalog[provider].append((model_id, models_dev_api(provider, model_id), metadata.get("reasoning") is True, effort_map(metadata.get("reasoning_options")), context_limit))
|
|
102
|
+
return {provider: sorted(set(models)) for provider, models in catalog.items()}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
OPENAI_PREFIXES = ("gpt-", "o1", "o3", "o4")
|
|
107
|
+
OPENAI_CHAT_COMPLETIONS_PREFIXES = ("o1", "o3", "o4")
|
|
108
|
+
GEMINI_ALLOWED_PREFIXES = ("gemini-2.5-", "gemini-3")
|
|
109
|
+
GEMINI_SKIP_TOKENS = ("embedding", "image", "tts", "live", "robotics", "computer")
|
|
110
|
+
DEPRECATED_STATUSES = {"deprecated", "decommissioned", "retired", "disabled"}
|
|
111
|
+
EXISTING_MODEL_RE = re.compile(
|
|
112
|
+
r'ModelInfo\(id="([^"]+)", provider="([^"]+)", api="([^"]+)"\)'
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
FALLBACK_MODELS: dict[str, tuple[tuple[str, str], ...]] = {
|
|
116
|
+
"openai": (
|
|
117
|
+
("gpt-4.1", "responses"),
|
|
118
|
+
("gpt-4.1-mini", "responses"),
|
|
119
|
+
("gpt-4o-mini", "responses"),
|
|
120
|
+
("gpt-5.6-luna", "responses"),
|
|
121
|
+
("o3", "chat_completions"),
|
|
122
|
+
),
|
|
123
|
+
"anthropic": (
|
|
124
|
+
("claude-fable-5", "messages"),
|
|
125
|
+
("claude-haiku-4-5", "messages"),
|
|
126
|
+
("claude-opus-5", "messages"),
|
|
127
|
+
("claude-sonnet-5", "messages"),
|
|
128
|
+
),
|
|
129
|
+
"gemini": (
|
|
130
|
+
("gemini-3.1-pro-preview", "generate_content"),
|
|
131
|
+
("gemini-3.5-flash", "generate_content"),
|
|
132
|
+
("gemini-3.6-flash", "generate_content"),
|
|
133
|
+
("gemini-3.7-flash", "generate_content"),
|
|
134
|
+
),
|
|
135
|
+
"grok": (
|
|
136
|
+
("grok-4.3", "chat_completions"),
|
|
137
|
+
("grok-4.5", "chat_completions"),
|
|
138
|
+
("grok-4.6", "chat_completions"),
|
|
139
|
+
("grok-build-0.1", "chat_completions"),
|
|
140
|
+
),
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _is_deprecated(model: dict) -> bool:
|
|
145
|
+
"""Return true only when a provider explicitly marks a model unavailable."""
|
|
146
|
+
if model.get("deprecated") is True:
|
|
147
|
+
return True
|
|
148
|
+
for field in ("status", "lifecycle", "state"):
|
|
149
|
+
value = model.get(field)
|
|
150
|
+
if isinstance(value, str) and value.strip().lower() in DEPRECATED_STATUSES:
|
|
151
|
+
return True
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def openai_api(model_id: str) -> str:
|
|
156
|
+
if model_id.startswith(OPENAI_CHAT_COMPLETIONS_PREFIXES):
|
|
157
|
+
return "chat_completions"
|
|
158
|
+
return "responses"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def fetch_openai_models(api_key: str, base_url: str) -> list[tuple[str, str]]:
|
|
162
|
+
import httpx
|
|
163
|
+
|
|
164
|
+
response = httpx.get(
|
|
165
|
+
f"{base_url.rstrip('/')}/models",
|
|
166
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
167
|
+
timeout=30.0,
|
|
168
|
+
)
|
|
169
|
+
response.raise_for_status()
|
|
170
|
+
data = response.json().get("data", [])
|
|
171
|
+
return sorted(
|
|
172
|
+
(model_id, openai_api(model_id))
|
|
173
|
+
for model in data
|
|
174
|
+
if isinstance(model, dict)
|
|
175
|
+
and not _is_deprecated(model)
|
|
176
|
+
and isinstance((model_id := model.get("id")), str)
|
|
177
|
+
and model_id.startswith(OPENAI_PREFIXES)
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def fetch_anthropic_models(api_key: str, base_url: str) -> list[tuple[str, str]]:
|
|
182
|
+
import httpx
|
|
183
|
+
|
|
184
|
+
models: list[tuple[str, str]] = []
|
|
185
|
+
after_id: str | None = None
|
|
186
|
+
while True:
|
|
187
|
+
params: dict[str, str | int] = {"limit": 100}
|
|
188
|
+
if after_id:
|
|
189
|
+
params["after_id"] = after_id
|
|
190
|
+
response = httpx.get(
|
|
191
|
+
f"{base_url.rstrip('/')}/v1/models",
|
|
192
|
+
params=params,
|
|
193
|
+
headers={
|
|
194
|
+
"x-api-key": api_key,
|
|
195
|
+
"anthropic-version": "2023-06-01",
|
|
196
|
+
},
|
|
197
|
+
timeout=30.0,
|
|
198
|
+
)
|
|
199
|
+
response.raise_for_status()
|
|
200
|
+
payload = response.json()
|
|
201
|
+
for model in payload.get("data", []):
|
|
202
|
+
if not isinstance(model, dict):
|
|
203
|
+
continue
|
|
204
|
+
if _is_deprecated(model):
|
|
205
|
+
continue
|
|
206
|
+
model_id = model.get("id")
|
|
207
|
+
if isinstance(model_id, str) and model_id.startswith("claude-"):
|
|
208
|
+
models.append((model_id, "messages"))
|
|
209
|
+
if not payload.get("has_more"):
|
|
210
|
+
break
|
|
211
|
+
after_id = payload.get("last_id")
|
|
212
|
+
if not after_id:
|
|
213
|
+
break
|
|
214
|
+
return sorted(set(models))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _gemini_supported(model: dict) -> bool:
|
|
218
|
+
methods = model.get("supportedGenerationMethods") or []
|
|
219
|
+
return "generateContent" in methods or "streamGenerateContent" in methods
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def fetch_gemini_models(api_key: str, base_url: str) -> list[tuple[str, str]]:
|
|
223
|
+
import httpx
|
|
224
|
+
|
|
225
|
+
models: list[tuple[str, str]] = []
|
|
226
|
+
page_token: str | None = None
|
|
227
|
+
while True:
|
|
228
|
+
params = {"key": api_key}
|
|
229
|
+
if page_token:
|
|
230
|
+
params["pageToken"] = page_token
|
|
231
|
+
response = httpx.get(
|
|
232
|
+
f"{base_url.rstrip('/')}/models",
|
|
233
|
+
params=params,
|
|
234
|
+
timeout=30.0,
|
|
235
|
+
)
|
|
236
|
+
response.raise_for_status()
|
|
237
|
+
payload = response.json()
|
|
238
|
+
for model in payload.get("models", []):
|
|
239
|
+
if (
|
|
240
|
+
not isinstance(model, dict)
|
|
241
|
+
or _is_deprecated(model)
|
|
242
|
+
or not _gemini_supported(model)
|
|
243
|
+
):
|
|
244
|
+
continue
|
|
245
|
+
name = model.get("name")
|
|
246
|
+
if not isinstance(name, str):
|
|
247
|
+
continue
|
|
248
|
+
model_id = name.removeprefix("models/")
|
|
249
|
+
lowered = model_id.lower()
|
|
250
|
+
if not model_id.startswith(GEMINI_ALLOWED_PREFIXES):
|
|
251
|
+
continue
|
|
252
|
+
if any(token in lowered for token in GEMINI_SKIP_TOKENS):
|
|
253
|
+
continue
|
|
254
|
+
models.append((model_id, "generate_content"))
|
|
255
|
+
page_token = payload.get("nextPageToken")
|
|
256
|
+
if not page_token:
|
|
257
|
+
break
|
|
258
|
+
return sorted(set(models))
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def read_existing_catalog(path: Path = OUTPUT) -> dict[str, list[tuple[str, str]]]:
|
|
262
|
+
found: dict[str, list[tuple[str, str]]] = {
|
|
263
|
+
"openai": [],
|
|
264
|
+
"anthropic": [],
|
|
265
|
+
"gemini": [],
|
|
266
|
+
"grok": [],
|
|
267
|
+
}
|
|
268
|
+
if path.exists():
|
|
269
|
+
for model_id, provider, api in EXISTING_MODEL_RE.findall(path.read_text()):
|
|
270
|
+
if provider in found:
|
|
271
|
+
found[provider].append((model_id, api))
|
|
272
|
+
for provider, models in found.items():
|
|
273
|
+
if not models:
|
|
274
|
+
found[provider] = list(FALLBACK_MODELS[provider])
|
|
275
|
+
return found
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def load_provider_models(
|
|
279
|
+
provider: str,
|
|
280
|
+
fetch: Callable[[], list[tuple[str, str]]],
|
|
281
|
+
*,
|
|
282
|
+
existing: list[tuple[str, str]],
|
|
283
|
+
strict: bool,
|
|
284
|
+
) -> list[tuple[str, str]]:
|
|
285
|
+
try:
|
|
286
|
+
models = fetch()
|
|
287
|
+
if models:
|
|
288
|
+
return models
|
|
289
|
+
if strict:
|
|
290
|
+
raise RuntimeError(f"{provider} returned no models")
|
|
291
|
+
except Exception:
|
|
292
|
+
if strict:
|
|
293
|
+
raise
|
|
294
|
+
return list(existing)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def render(catalog: dict[str, Iterable[tuple]]) -> str:
|
|
298
|
+
lines = [
|
|
299
|
+
"# Generated by scripts/generate_models.py. Do not edit by hand.",
|
|
300
|
+
"",
|
|
301
|
+
"from core_ai.models.types import ModelInfo",
|
|
302
|
+
"",
|
|
303
|
+
]
|
|
304
|
+
tuple_names = []
|
|
305
|
+
for provider, models in catalog.items():
|
|
306
|
+
name = f"{provider.upper()}_MODELS"
|
|
307
|
+
tuple_names.append(name)
|
|
308
|
+
lines.append(f"{name} = (")
|
|
309
|
+
for model in models:
|
|
310
|
+
if len(model) == 2:
|
|
311
|
+
model_id, api = model
|
|
312
|
+
reasoning = False
|
|
313
|
+
thinking_level_map = ()
|
|
314
|
+
context_limit = None
|
|
315
|
+
else:
|
|
316
|
+
model_id, api, reasoning, thinking_level_map, context_limit = model
|
|
317
|
+
args = [
|
|
318
|
+
f'id="{model_id}"',
|
|
319
|
+
f'provider="{provider}"',
|
|
320
|
+
f'api="{api}"',
|
|
321
|
+
]
|
|
322
|
+
if reasoning:
|
|
323
|
+
args.append("reasoning=True")
|
|
324
|
+
if thinking_level_map:
|
|
325
|
+
args.append(f"thinking_level_map={thinking_level_map!r}")
|
|
326
|
+
if context_limit is not None:
|
|
327
|
+
args.append(f"context_limit={context_limit}")
|
|
328
|
+
lines.append(f" ModelInfo({', '.join(args)}),")
|
|
329
|
+
lines.extend((")", ""))
|
|
330
|
+
lines.append(f"ALL_MODELS = {' + '.join(tuple_names)}")
|
|
331
|
+
lines.append("")
|
|
332
|
+
return "\n".join(lines)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def collect_catalog(*, strict: bool = False, output: Path = OUTPUT) -> dict[str, list[tuple[str, str]]]:
|
|
336
|
+
"""Build our catalog from the curated models.dev registry.
|
|
337
|
+
|
|
338
|
+
Provider credentials are deliberately not required: models.dev is the
|
|
339
|
+
catalog source, while credentials are only needed when a model is used.
|
|
340
|
+
Keeping the checked-in snapshot on failure makes package builds offline
|
|
341
|
+
safe. Set CORE_AI_MODELS_DEV=0 to intentionally skip the network request.
|
|
342
|
+
"""
|
|
343
|
+
existing = read_existing_catalog(output)
|
|
344
|
+
if os.environ.get("CORE_AI_MODELS_DEV", "1") == "0":
|
|
345
|
+
return existing
|
|
346
|
+
|
|
347
|
+
try:
|
|
348
|
+
catalog = fetch_models_dev_models(
|
|
349
|
+
os.environ.get("MODELS_DEV_URL", MODELS_DEV_URL)
|
|
350
|
+
)
|
|
351
|
+
if not any(catalog.values()):
|
|
352
|
+
raise RuntimeError("models.dev returned no supported models")
|
|
353
|
+
return catalog
|
|
354
|
+
except Exception:
|
|
355
|
+
if strict:
|
|
356
|
+
raise
|
|
357
|
+
return existing
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def generate(*, strict: bool = False, output: Path = OUTPUT) -> Path:
|
|
361
|
+
catalog = collect_catalog(strict=strict, output=output)
|
|
362
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
363
|
+
output.write_text(render(catalog))
|
|
364
|
+
total = sum(len(models) for models in catalog.values())
|
|
365
|
+
print(f"wrote {total} models to {output}")
|
|
366
|
+
return output
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def main() -> None:
|
|
370
|
+
# Local development credentials are only loaded for direct script runs;
|
|
371
|
+
# explicit environment variables still win over values from .env.
|
|
372
|
+
try:
|
|
373
|
+
from dotenv import load_dotenv
|
|
374
|
+
except ImportError:
|
|
375
|
+
pass
|
|
376
|
+
else:
|
|
377
|
+
load_dotenv(Path(__file__).resolve().parents[1] / ".env")
|
|
378
|
+
generate(strict=os.environ.get("CORE_AI_GENERATE_STRICT") == "1")
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
if __name__ == "__main__":
|
|
382
|
+
main()
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from core_ai.content import (
|
|
2
|
+
Content,
|
|
3
|
+
IMAGE_MIME_BY_SUFFIX,
|
|
4
|
+
estimate_content_tokens,
|
|
5
|
+
image_part,
|
|
6
|
+
image_part_from_bytes,
|
|
7
|
+
normalize_content,
|
|
8
|
+
sniff_image_media_type,
|
|
9
|
+
split_text_and_images,
|
|
10
|
+
text_from_content,
|
|
11
|
+
to_anthropic_blocks,
|
|
12
|
+
to_gemini_parts,
|
|
13
|
+
to_openai_chat_content,
|
|
14
|
+
to_openai_responses_content,
|
|
15
|
+
)
|
|
16
|
+
from core_ai.models import ModelAPI, ModelCatalog, ModelInfo, get_model, list_models
|
|
17
|
+
from core_ai.providers.anthropic import AnthropicProvider
|
|
18
|
+
from core_ai.providers.base import BaseProvider
|
|
19
|
+
from core_ai.providers.catalog import (
|
|
20
|
+
PROVIDERS,
|
|
21
|
+
MissingProviderCredentials,
|
|
22
|
+
ProviderSpec,
|
|
23
|
+
configured_provider_ids,
|
|
24
|
+
find_provider,
|
|
25
|
+
get_provider,
|
|
26
|
+
has_configured_provider,
|
|
27
|
+
provider_is_configured,
|
|
28
|
+
)
|
|
29
|
+
from core_ai.providers.defaults import build_default_registry, default_model_id
|
|
30
|
+
from core_ai.providers.gemini import GeminiProvider
|
|
31
|
+
from core_ai.providers.grok import GrokProvider
|
|
32
|
+
from core_ai.providers.local import LocalProvider
|
|
33
|
+
from core_ai.providers.ollama import OllamaProvider
|
|
34
|
+
from core_ai.providers.openai import OpenAIProvider
|
|
35
|
+
from core_ai.providers.openrouter import OpenRouterProvider
|
|
36
|
+
from core_ai.providers.vercel import VercelProvider
|
|
37
|
+
from core_ai.registry import ModelRegistry
|
|
38
|
+
from core_ai.types import Message, StreamEvent
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"AnthropicProvider",
|
|
42
|
+
"BaseProvider",
|
|
43
|
+
"Content",
|
|
44
|
+
"GeminiProvider",
|
|
45
|
+
"GrokProvider",
|
|
46
|
+
"IMAGE_MIME_BY_SUFFIX",
|
|
47
|
+
"LocalProvider",
|
|
48
|
+
"Message",
|
|
49
|
+
"MissingProviderCredentials",
|
|
50
|
+
"ModelAPI",
|
|
51
|
+
"ModelCatalog",
|
|
52
|
+
"ModelInfo",
|
|
53
|
+
"ModelRegistry",
|
|
54
|
+
"OllamaProvider",
|
|
55
|
+
"OpenAIProvider",
|
|
56
|
+
"OpenRouterProvider",
|
|
57
|
+
"PROVIDERS",
|
|
58
|
+
"ProviderSpec",
|
|
59
|
+
"StreamEvent",
|
|
60
|
+
"VercelProvider",
|
|
61
|
+
"build_default_registry",
|
|
62
|
+
"configured_provider_ids",
|
|
63
|
+
"default_model_id",
|
|
64
|
+
"estimate_content_tokens",
|
|
65
|
+
"find_provider",
|
|
66
|
+
"get_model",
|
|
67
|
+
"get_provider",
|
|
68
|
+
"has_configured_provider",
|
|
69
|
+
"image_part",
|
|
70
|
+
"image_part_from_bytes",
|
|
71
|
+
"list_models",
|
|
72
|
+
"normalize_content",
|
|
73
|
+
"provider_is_configured",
|
|
74
|
+
"sniff_image_media_type",
|
|
75
|
+
"split_text_and_images",
|
|
76
|
+
"text_from_content",
|
|
77
|
+
"to_anthropic_blocks",
|
|
78
|
+
"to_gemini_parts",
|
|
79
|
+
"to_openai_chat_content",
|
|
80
|
+
"to_openai_responses_content",
|
|
81
|
+
]
|