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.
Files changed (40) hide show
  1. symphony_core-0.0.1/.gitignore +7 -0
  2. symphony_core-0.0.1/LICENSE +21 -0
  3. symphony_core-0.0.1/PKG-INFO +53 -0
  4. symphony_core-0.0.1/README.md +37 -0
  5. symphony_core-0.0.1/hatch_build.py +54 -0
  6. symphony_core-0.0.1/pyproject.toml +51 -0
  7. symphony_core-0.0.1/scripts/generate_models.py +382 -0
  8. symphony_core-0.0.1/src/core_ai/__init__.py +81 -0
  9. symphony_core-0.0.1/src/core_ai/content.py +384 -0
  10. symphony_core-0.0.1/src/core_ai/models/__init__.py +18 -0
  11. symphony_core-0.0.1/src/core_ai/models/generated.py +110 -0
  12. symphony_core-0.0.1/src/core_ai/models/registry.py +53 -0
  13. symphony_core-0.0.1/src/core_ai/models/types.py +20 -0
  14. symphony_core-0.0.1/src/core_ai/oauth/__init__.py +31 -0
  15. symphony_core-0.0.1/src/core_ai/oauth/anthropic.py +145 -0
  16. symphony_core-0.0.1/src/core_ai/oauth/callback.py +56 -0
  17. symphony_core-0.0.1/src/core_ai/oauth/login.py +242 -0
  18. symphony_core-0.0.1/src/core_ai/oauth/openai_codex.py +199 -0
  19. symphony_core-0.0.1/src/core_ai/oauth/pkce.py +70 -0
  20. symphony_core-0.0.1/src/core_ai/oauth/runtime.py +89 -0
  21. symphony_core-0.0.1/src/core_ai/oauth/store.py +121 -0
  22. symphony_core-0.0.1/src/core_ai/oauth/types.py +75 -0
  23. symphony_core-0.0.1/src/core_ai/oauth/xai.py +187 -0
  24. symphony_core-0.0.1/src/core_ai/providers/__init__.py +0 -0
  25. symphony_core-0.0.1/src/core_ai/providers/anthropic.py +296 -0
  26. symphony_core-0.0.1/src/core_ai/providers/base.py +36 -0
  27. symphony_core-0.0.1/src/core_ai/providers/catalog.py +241 -0
  28. symphony_core-0.0.1/src/core_ai/providers/defaults.py +289 -0
  29. symphony_core-0.0.1/src/core_ai/providers/gemini.py +327 -0
  30. symphony_core-0.0.1/src/core_ai/providers/grok.py +58 -0
  31. symphony_core-0.0.1/src/core_ai/providers/http.py +312 -0
  32. symphony_core-0.0.1/src/core_ai/providers/local.py +82 -0
  33. symphony_core-0.0.1/src/core_ai/providers/ollama.py +141 -0
  34. symphony_core-0.0.1/src/core_ai/providers/openai.py +570 -0
  35. symphony_core-0.0.1/src/core_ai/providers/openrouter.py +99 -0
  36. symphony_core-0.0.1/src/core_ai/providers/vercel.py +195 -0
  37. symphony_core-0.0.1/src/core_ai/providers/vercel_evaluation.py +288 -0
  38. symphony_core-0.0.1/src/core_ai/py.typed +0 -0
  39. symphony_core-0.0.1/src/core_ai/registry.py +97 -0
  40. symphony_core-0.0.1/src/core_ai/types.py +45 -0
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ .env
4
+ secrets.json
5
+ .env.*
6
+ .DS_Store
7
+ .symphony
@@ -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
+ ]