Skip to content

Commit efd5153

Browse files
authored
fix: delegate default model selection to providers (#48)
## What - delegates the no-override model choice to each provider instead of pinning library-owned names - preserves one precedence chain: task field > legacy metadata > constructor override > provider-native - omits vendor model kwargs when selection is provider-native - records `model_source` on every provider result and records `model` only when known - keeps `default_model=` as an explicit migration override - makes configured model allow-lists fail closed when provider-native selection cannot be verified ## Why Hard-coded adapter defaults age faster than the SDK and can override supported provider configuration. The SDK-evolution docs already described Claude and Antigravity as provider-native, but the adapters silently forced stale model names. ## Root cause Model resolution treated the library constructor default as mandatory rather than an optional override, and result metadata could not distinguish task, metadata, constructor, and provider-native selection. ## Compatibility This is an intentional pre-1.0 behavior change. Applications that need the prior pin can pass the same value through `default_model=`; task and metadata overrides keep their existing precedence. ## Checks - `ruff check src tests` - strict `mypy` - full all-extras suite: 396 passed, 3 skipped - model-policy coverage run: 356 passed, 3 skipped; 91.00% coverage - installed SDK contracts: 23 passed - `uv lock --check` - `uv build` ## Stack - Base: #47 - Next: #49
1 parent 5d96201 commit efd5153

13 files changed

Lines changed: 350 additions & 60 deletions

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3131

3232
### Changed
3333

34+
- BREAKING: built-in adapters no longer pin library-owned default model names.
35+
Model selection now follows task field > legacy metadata > `default_model=`
36+
constructor override > provider-native configuration. Provider-native calls
37+
omit the model kwarg; results expose `model_source` and only report a model
38+
value when known. Existing constructor overrides remain supported.
3439
- Pydantic structured-output parsing now requests strict validation, so values
3540
such as `"42"` no longer coerce into integer fields.
3641
- BREAKING: task and value-object constructors now reject blank identities,
@@ -62,6 +67,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6267
- Configured model allow-lists, Antigravity MCP name syntax, disjoint
6368
allow/deny lists, and Antigravity's legacy reasoning-effort alias are rejected
6469
during static preflight instead of surfacing later or being silently ignored.
70+
- A configured model allow-list now fails closed when model selection is
71+
provider-native and therefore cannot be verified locally.
6572

6673
## 0.4.0 - 2026-07-02
6774

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,13 @@ a field (for example only Claude maps `budget_usd`; Codex and Antigravity reject
141141
it with a typed `UnsupportedTaskInputError`) the adapter raises rather than
142142
silently dropping it.
143143

144+
Model selection follows one explicit precedence chain: `AgentTask.model`, then
145+
legacy `metadata["model"]`, then an adapter's `default_model=` constructor
146+
override, then the provider's native configuration/default. Adapters omit the
147+
vendor model option at the final step rather than pinning a library-owned model.
148+
Results record `metadata["model_source"]`; `metadata["model"]` appears only when
149+
the kit knows the selected value.
150+
144151
Call `validate_task(runtime, task)` (or `kit.validate_task("codex", task)`) to
145152
inspect every statically detectable incompatibility before dispatch. The
146153
returned `TaskSupportReport` is side-effect-free and lists source fields such as

docs/api-stability.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,13 @@ import the names from the top-level package instead.
5757
without the extension maps a missing package to `NOT_READY` and present
5858
package to `INDETERMINATE`. `READY_TO_ATTEMPT` establishes only that known
5959
setup signals are present; it is not a guarantee of future execution.
60+
- **Providers own the default model.** With no task, legacy metadata, or
61+
`default_model=` override, built-in adapters omit the SDK model option and let
62+
the provider's supported configuration select it. The effective precedence is
63+
task field, metadata alias, constructor override, provider-native. Result
64+
metadata always records `model_source` and records `model` only when known.
65+
A configured `supported_models` allow-list requires an explicit/verifiable
66+
selection and fails closed on provider-native selection.
6067
- **`AgentKit` is sugar, not a second API.** The hub assembles the same frozen
6168
`AgentTask` and returns the same `AgentResult` the runtimes produce
6269
(`ParsedResult` is a runtime-identical subclass adding only the typed

docs/capability-matrix.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
|------------|------------------|------------------|------------------------|
55
| Optional extra | `claude` | `codex` | `antigravity` |
66
| Core import without extra | Yes | Yes | Yes |
7+
| Model selection | Provider-native unless task/metadata/constructor overrides it | Provider-native unless task/metadata/constructor overrides it | Provider-native unless task/metadata/constructor overrides it |
78
| Working directory | Yes | Yes | Yes |
89
| Session resume | Yes | Yes | Yes |
910
| Structured output | Native `output_format` when available | Native output schema / JSON parse fallback | Native response schema / JSON parse fallback |

docs/providers.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,14 @@ to the exact missing extra.
8787

8888
All three adapters map the task's system prompt (Claude `system_prompt`, Codex
8989
`developer_instructions`, Antigravity `system_instructions`) and the `model`
90-
field (falling back to the `metadata` aliases of the same names).
90+
field. Model precedence is `AgentTask.model` > legacy `metadata["model"]` > the
91+
adapter's `default_model=` constructor override > provider-native selection. At
92+
the final step the adapter omits the SDK model option; it does not impose a
93+
library-owned model that can go stale. Results always include
94+
`metadata["model_source"]` (`task`, `metadata`, `constructor`, or
95+
`provider-native`) and include `metadata["model"]` only when the selected value
96+
is known. When `supported_models=` is configured, provider-native selection is
97+
rejected as unverifiable until the caller chooses an explicit model.
9198
`reasoning_effort` maps to the Claude and Codex `effort` options; Antigravity
9299
has no reasoning-effort control and rejects the first-class field with a typed
93100
error. Its legacy `metadata["reasoning_effort"]` alias is rejected too, rather

src/agent_runtime_kit/adapters/_common.py

Lines changed: 65 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55
import inspect
66
import os
77
from collections.abc import Iterable, Mapping
8+
from dataclasses import dataclass
89
from importlib import metadata
910
from math import isfinite
10-
from typing import Any
11+
from typing import Any, Literal
1112

1213
from agent_runtime_kit._errors import UnsupportedTaskInputError
1314
from agent_runtime_kit._schema import (
@@ -26,6 +27,16 @@
2627
)
2728
from agent_runtime_kit.compatibility import compatibility_for
2829

30+
ModelSource = Literal["task", "metadata", "constructor", "provider-native"]
31+
32+
33+
@dataclass(frozen=True)
34+
class ModelSelection:
35+
"""Effective model value and the layer that selected it."""
36+
37+
value: str | None
38+
source: ModelSource
39+
2940

3041
def package_availability(kind: AgentRuntimeKind) -> RuntimeAvailability:
3142
"""Return installed-distribution availability without resolving a module."""
@@ -58,26 +69,69 @@ def package_version(package_name: str) -> str | None:
5869
return None
5970

6071

72+
def select_model(task: AgentTask, default_model: str | None) -> ModelSelection:
73+
"""Resolve model precedence without inventing a provider default."""
74+
75+
if task.model is not None:
76+
return ModelSelection(task.model, "task")
77+
metadata_model = metadata_str(task.metadata, "model")
78+
if metadata_model is not None:
79+
return ModelSelection(metadata_model, "metadata")
80+
if default_model is not None:
81+
return ModelSelection(default_model, "constructor")
82+
return ModelSelection(None, "provider-native")
83+
84+
85+
def validate_model_configuration(
86+
default_model: str | None,
87+
supported_models: tuple[str, ...] | None,
88+
) -> tuple[str, ...] | None:
89+
"""Validate and freeze adapter-level model overrides and allow-lists."""
90+
91+
if default_model is not None and (
92+
not isinstance(default_model, str) or not default_model.strip()
93+
):
94+
raise ValueError("default_model must be a non-empty string or None")
95+
if supported_models is None:
96+
return None
97+
if isinstance(supported_models, (str, bytes)):
98+
raise ValueError("supported_models must be a sequence, not a scalar string")
99+
values = tuple(supported_models)
100+
if any(not isinstance(value, str) or not value.strip() for value in values):
101+
raise ValueError("supported_models must contain only non-empty strings")
102+
if len(values) != len(set(values)):
103+
raise ValueError("supported_models must not contain duplicates")
104+
return values
105+
106+
61107
def model_support_issue(
62108
*,
63-
task: AgentTask,
64-
model: str,
109+
selection: ModelSelection,
65110
supported_models: tuple[str, ...] | None,
66111
) -> TaskSupportIssue | None:
67112
"""Report a configured model allow-list mismatch at the source field."""
68113

69-
if supported_models is None or model in supported_models:
114+
if supported_models is None:
70115
return None
71116
supported = ", ".join(supported_models) or "(none)"
72-
if task.model is not None:
73-
field = "model"
74-
elif metadata_str(task.metadata, "model") is not None:
75-
field = "metadata.model"
76-
else:
77-
field = "model"
117+
if selection.value is None:
118+
return TaskSupportIssue(
119+
"model",
120+
"runtime has a configured model allow-list, but provider-native "
121+
"selection cannot be verified; select an explicit model from: "
122+
f"{supported}",
123+
)
124+
if selection.value in supported_models:
125+
return None
126+
field = {
127+
"task": "model",
128+
"metadata": "metadata.model",
129+
"constructor": "default_model",
130+
"provider-native": "model",
131+
}[selection.source]
78132
return TaskSupportIssue(
79133
field,
80-
f"model {model!r} is not supported by this runtime; supported: {supported}",
134+
f"model {selection.value!r} is not supported by this runtime; supported: {supported}",
81135
)
82136

83137

src/agent_runtime_kit/adapters/antigravity.py

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,14 @@
3535
empty_completion_error,
3636
filter_supported_kwargs,
3737
fingerprint_item,
38-
metadata_str,
3938
model_support_issue,
4039
optional_int,
4140
optional_str,
4241
output_schema_from,
4342
package_availability,
4443
resolve_structured_output,
44+
select_model,
45+
validate_model_configuration,
4546
)
4647
from agent_runtime_kit.events import (
4748
output_delta_event,
@@ -78,7 +79,7 @@ class AntigravityAgentRuntime:
7879
def __init__(
7980
self,
8081
*,
81-
default_model: str = "gemini-3.5-flash",
82+
default_model: str | None = None,
8283
supported_models: tuple[str, ...] | None = None,
8384
api_key: str | None = None,
8485
vertex: bool | None = None,
@@ -92,7 +93,9 @@ def __init__(
9293
reuse_process: bool = False,
9394
) -> None:
9495
self._default_model = default_model
95-
self._supported_models = supported_models
96+
self._supported_models = validate_model_configuration(
97+
default_model, supported_models
98+
)
9699
self._api_key = api_key
97100
self._vertex = vertex
98101
self._project = project
@@ -197,9 +200,9 @@ def validate_task(self, task: AgentTask) -> TaskSupportReport:
197200

198201
report = _validate_declared_task_support(self.kind, self.capabilities, task)
199202
issues = list(report.issues)
203+
selection = select_model(task, self._default_model)
200204
model_issue = model_support_issue(
201-
task=task,
202-
model=self._model(task),
205+
selection=selection,
203206
supported_models=self._supported_models,
204207
)
205208
if model_issue is not None:
@@ -229,7 +232,8 @@ async def run(self, task: AgentTask) -> AgentResult:
229232
await safe_emit(task, task_started_event(task, self.kind))
230233
try:
231234
require_task_support(self.validate_task(task))
232-
model = self._model(task)
235+
selection = select_model(task, self._default_model)
236+
model = selection.value
233237
sdk = self._load_sdk()
234238
# Resolve auth off the event loop: ADC discovery can call
235239
# google.auth.default(), which reads files and may hit the GCE metadata
@@ -243,7 +247,12 @@ async def run(self, task: AgentTask) -> AgentResult:
243247
)
244248
config, dropped = self._build_config(task, model=model, auth=auth, sdk=sdk)
245249
result = await self._invoke(
246-
task, config=config, sdk=sdk, model=model, dropped_options=dropped
250+
task,
251+
config=config,
252+
sdk=sdk,
253+
model=model,
254+
model_source=selection.source,
255+
dropped_options=dropped,
247256
)
248257
except Exception as exc:
249258
await safe_emit(task, task_failed_event(task, self.kind, error=str(exc)))
@@ -307,7 +316,7 @@ def _build_config(
307316
self,
308317
task: AgentTask,
309318
*,
310-
model: str,
319+
model: str | None,
311320
auth: _AntigravityAuthConfig,
312321
sdk: _AntigravitySDK,
313322
) -> tuple[Any, list[str]]:
@@ -321,7 +330,6 @@ def _build_config(
321330
capabilities, policies = _capability_policy(self.kind, task, sdk)
322331
schema = output_schema_from(task.output_schema, task.metadata)
323332
config_kwargs: dict[str, Any] = {
324-
"model": model,
325333
"api_key": auth.api_key,
326334
"vertex": auth.vertex,
327335
"project": auth.project,
@@ -343,6 +351,8 @@ def _build_config(
343351
for server in task.mcp_servers
344352
],
345353
}
354+
if model is not None:
355+
config_kwargs["model"] = model
346356
# Tolerate vendor option drift like Claude/Codex: drop kwargs the installed
347357
# LocalAgentConfig no longer accepts (instead of a TypeError) and record
348358
# them — except the tool posture (and workspace scoping when requested),
@@ -361,7 +371,8 @@ async def _invoke(
361371
*,
362372
config: Any,
363373
sdk: _AntigravitySDK,
364-
model: str,
374+
model: str | None,
375+
model_source: str,
365376
dropped_options: list[str] | None = None,
366377
) -> AgentResult:
367378
text_parts: list[str] = []
@@ -426,10 +437,12 @@ async def _invoke(
426437
self._process_reuse_metadata(process_reused) if self._reuse_process else None
427438
)
428439
metadata: dict[str, Any] = {
429-
"model": model,
440+
"model_source": model_source,
430441
"sdk": "google_antigravity",
431442
**dict(process_metadata or {}),
432443
}
444+
if model is not None:
445+
metadata["model"] = model
433446
if dropped_options:
434447
metadata["dropped_options"] = list(dropped_options)
435448

@@ -661,9 +674,6 @@ def _auth_config(self) -> _AntigravityAuthConfig:
661674
return _AntigravityAuthConfig(source="none")
662675
return self._vertex_auth_config()
663676

664-
def _model(self, task: AgentTask) -> str:
665-
return task.model or metadata_str(task.metadata, "model") or self._default_model
666-
667677
def _runtime_dir(self, name: str) -> Path:
668678
base = self._data_dir or _default_data_dir()
669679
path = base / name

0 commit comments

Comments
 (0)