diff --git a/adapters/README.md b/adapters/README.md index 51e5bfbab..40dcceb88 100644 --- a/adapters/README.md +++ b/adapters/README.md @@ -114,6 +114,12 @@ and additive extension maps because their support does not vary by adapter: The selected model role is `default`, or the sole configured role when no `default` exists. More than one role without `default` fails planning. +Claude and Codex publish a descriptor-owned `model_schema` for every configured +model role. Their native providers (`anthropic` and `openai`, respectively) +keep the existing authentication path. Other providers remain valid only with +an explicit `base_url` and `api_key_env`. The same schema rejects undeclared +`ModelConfig.settings` during planning and reports each issue through +`doctor(...)` before adapter startup. `runtime.max_turns` is optional; omitting it preserves adapter-native defaults without creating a compatibility requirement. diff --git a/adapters/claude/fabric-adapter.json b/adapters/claude/fabric-adapter.json index d04516c6d..da212d390 100644 --- a/adapters/claude/fabric-adapter.json +++ b/adapters/claude/fabric-adapter.json @@ -6,6 +6,31 @@ "runner": { "module": "nemo_fabric_adapters.claude.adapter" }, + "model_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "provider": {"type": "string", "minLength": 1}, + "model": {"type": "string", "minLength": 1}, + "temperature": {"type": "number"}, + "api_key_env": {"type": "string", "minLength": 1}, + "base_url": {"type": "string", "minLength": 1}, + "settings": { + "type": "object", + "properties": {}, + "additionalProperties": false + } + }, + "required": ["provider", "model"], + "if": { + "properties": {"provider": {"const": "anthropic"}}, + "required": ["provider"] + }, + "else": { + "required": ["base_url", "api_key_env"] + }, + "additionalProperties": false + }, "settings_schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", diff --git a/adapters/codex/fabric-adapter.json b/adapters/codex/fabric-adapter.json index 8ff6bd521..84333740a 100644 --- a/adapters/codex/fabric-adapter.json +++ b/adapters/codex/fabric-adapter.json @@ -6,6 +6,31 @@ "runner": { "module": "nemo_fabric_adapters.codex.adapter" }, + "model_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "provider": {"type": "string", "minLength": 1}, + "model": {"type": "string", "minLength": 1}, + "temperature": {"type": "number"}, + "api_key_env": {"type": "string", "minLength": 1}, + "base_url": {"type": "string", "minLength": 1}, + "settings": { + "type": "object", + "properties": {}, + "additionalProperties": false + } + }, + "required": ["provider", "model"], + "if": { + "properties": {"provider": {"const": "openai"}}, + "required": ["provider"] + }, + "else": { + "required": ["base_url", "api_key_env"] + }, + "additionalProperties": false + }, "settings_schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", diff --git a/crates/fabric-cli/assets/adapters/claude/fabric-adapter.json b/crates/fabric-cli/assets/adapters/claude/fabric-adapter.json index d04516c6d..da212d390 100644 --- a/crates/fabric-cli/assets/adapters/claude/fabric-adapter.json +++ b/crates/fabric-cli/assets/adapters/claude/fabric-adapter.json @@ -6,6 +6,31 @@ "runner": { "module": "nemo_fabric_adapters.claude.adapter" }, + "model_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "provider": {"type": "string", "minLength": 1}, + "model": {"type": "string", "minLength": 1}, + "temperature": {"type": "number"}, + "api_key_env": {"type": "string", "minLength": 1}, + "base_url": {"type": "string", "minLength": 1}, + "settings": { + "type": "object", + "properties": {}, + "additionalProperties": false + } + }, + "required": ["provider", "model"], + "if": { + "properties": {"provider": {"const": "anthropic"}}, + "required": ["provider"] + }, + "else": { + "required": ["base_url", "api_key_env"] + }, + "additionalProperties": false + }, "settings_schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", diff --git a/crates/fabric-cli/assets/adapters/codex/fabric-adapter.json b/crates/fabric-cli/assets/adapters/codex/fabric-adapter.json index 8ff6bd521..84333740a 100644 --- a/crates/fabric-cli/assets/adapters/codex/fabric-adapter.json +++ b/crates/fabric-cli/assets/adapters/codex/fabric-adapter.json @@ -6,6 +6,31 @@ "runner": { "module": "nemo_fabric_adapters.codex.adapter" }, + "model_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "provider": {"type": "string", "minLength": 1}, + "model": {"type": "string", "minLength": 1}, + "temperature": {"type": "number"}, + "api_key_env": {"type": "string", "minLength": 1}, + "base_url": {"type": "string", "minLength": 1}, + "settings": { + "type": "object", + "properties": {}, + "additionalProperties": false + } + }, + "required": ["provider", "model"], + "if": { + "properties": {"provider": {"const": "openai"}}, + "required": ["provider"] + }, + "else": { + "required": ["base_url", "api_key_env"] + }, + "additionalProperties": false + }, "settings_schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", diff --git a/crates/fabric-core/src/config.rs b/crates/fabric-core/src/config.rs index ec9a5e668..10625bf60 100644 --- a/crates/fabric-core/src/config.rs +++ b/crates/fabric-core/src/config.rs @@ -212,6 +212,9 @@ pub struct AdapterDescriptor { /// JSON Schema for adapter-owned `harness.settings`. #[serde(default, skip_serializing_if = "Option::is_none")] pub settings_schema: Option>, + /// JSON Schema applied to every normalized `FabricConfig.models` entry. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_schema: Option>, /// JSON Schema for adapter-owned `FabricConfig.workflow`. #[serde(default, skip_serializing_if = "Option::is_none")] pub workflow_schema: Option>, @@ -1587,6 +1590,28 @@ pub(crate) fn adapter_config_compatibility_issues( )); } } + + if let Some(schema) = &descriptor.model_schema { + let schema = Value::Object(schema.clone()); + let validator = jsonschema::validator_for(&schema) + .expect("adapter model schema was validated during descriptor resolution"); + for (role, model) in &config.models { + let mut value = serde_json::to_value(model) + .expect("typed model configuration is always JSON serializable"); + if let Some(object) = value.as_object_mut() { + for extension in model.extensions.keys() { + object.remove(extension); + } + } + for error in validator.iter_errors(&value) { + issues.push(incompatible( + schema_error_path(&error, &format!("models.{role}")), + schema_error_reason(&error, "adapter model schema"), + )); + } + } + } + for (role, model) in &config.models { if model.base_url.is_some() && !accepts(AdapterConfigField::ModelBaseUrl) { issues.push(incompatible( @@ -1679,6 +1704,7 @@ fn validate_adapter_descriptor_shape(descriptor: &AdapterDescriptor, path: &Path } for (field, schema) in [ ("settings_schema", descriptor.settings_schema.as_ref()), + ("model_schema", descriptor.model_schema.as_ref()), ("workflow_schema", descriptor.workflow_schema.as_ref()), ( "tool_definition_schema", @@ -2792,6 +2818,23 @@ mod tests { .expect("typed config") } + fn config_with_model(adapter_id: &str, provider: &str) -> FabricConfig { + let mut config = typed_config(adapter_id); + config.models.insert( + "default".to_string(), + ModelConfig { + provider: provider.to_string(), + model: "test-model".to_string(), + temperature: None, + api_key_env: None, + base_url: None, + settings: serde_json::Map::new(), + extensions: BTreeMap::new(), + }, + ); + config + } + fn typed_workflow() -> WorkflowConfig { serde_json::from_value(serde_json::json!({ "entrypoint": { @@ -3240,6 +3283,97 @@ mod tests { })); } + #[test] + fn adapter_model_schemas_accept_native_and_explicit_custom_providers() { + for (adapter_id, native_provider) in [ + ("nvidia.fabric.claude", "anthropic"), + ("nvidia.fabric.codex", "openai"), + ] { + resolve_run_plan_from_config( + config_with_model(adapter_id, native_provider), + ResolveContext::new("/tmp/fabric-native-provider"), + ) + .expect("adapter-native provider"); + + let mut custom = config_with_model(adapter_id, "acme"); + let model = custom.models.get_mut("default").expect("default model"); + model.api_key_env = Some("ACME_API_KEY".to_string()); + model.base_url = Some("https://models.example/v1".to_string()); + resolve_run_plan_from_config( + custom, + ResolveContext::new("/tmp/fabric-custom-provider"), + ) + .expect("explicit custom provider"); + } + + resolve_run_plan_from_config( + config_with_model("nvidia.fabric.langchain.deepagents", "acme"), + ResolveContext::new("/tmp/fabric-dynamic-provider"), + ) + .expect("adapter without a model schema preserves dynamic providers"); + } + + #[test] + fn adapter_model_schemas_validate_every_model_role() { + for (adapter_id, native_provider) in [ + ("nvidia.fabric.claude", "anthropic"), + ("nvidia.fabric.codex", "openai"), + ] { + let mut config = config_with_model(adapter_id, native_provider); + config.models.insert( + "review".to_string(), + ModelConfig { + provider: "acme".to_string(), + model: "review-model".to_string(), + temperature: None, + api_key_env: None, + base_url: None, + settings: serde_json::Map::new(), + extensions: BTreeMap::new(), + }, + ); + let path = repository_root().join(match adapter_id { + "nvidia.fabric.claude" => "adapters/claude/fabric-adapter.json", + "nvidia.fabric.codex" => "adapters/codex/fabric-adapter.json", + _ => unreachable!("test adapter"), + }); + let descriptor = load_adapter_descriptor(&path).expect("adapter descriptor"); + + let fields = adapter_config_compatibility_issues(&config, Some(&descriptor)) + .into_iter() + .map(|issue| issue.field) + .collect::>(); + + assert!(fields.contains("models.review.base_url")); + assert!(fields.contains("models.review.api_key_env")); + } + } + + #[test] + fn adapter_model_schema_rejects_undeclared_settings() { + let mut config = config_with_model("nvidia.fabric.claude", "anthropic"); + config + .models + .get_mut("default") + .expect("default model") + .settings + .insert("api_timeout".to_string(), serde_json::json!(30)); + + let error = + resolve_run_plan_from_config(config, ResolveContext::new("/tmp/fabric-model-settings")) + .expect_err("undeclared model setting"); + + assert!(matches!( + error, + FabricError::AdapterCompatibility { + adapter_id, + field, + .. + } if adapter_id == "nvidia.fabric.claude" + && field == "models.default.settings.api_timeout" + )); + } + #[test] fn unsupported_enabled_tools_report_canonical_field() { let adapter_id = "nvidia.fabric.codex"; @@ -4150,6 +4284,37 @@ mod tests { } } + #[test] + fn adapter_model_schema_must_be_valid_and_allow_objects() { + let path = repository_root().join("adapters/claude/fabric-adapter.json"); + let valid_descriptor = load_adapter_descriptor(&path).expect("Claude descriptor"); + + for (schema, expected) in [ + ( + serde_json::json!({"type": 7}), + "model_schema is not valid JSON Schema", + ), + ( + serde_json::json!({"type": "string"}), + "model_schema root type must allow object instances", + ), + ] { + let mut descriptor = valid_descriptor.clone(); + descriptor.model_schema = + Some(schema.as_object().expect("model schema object").clone()); + + let error = validate_adapter_descriptor_shape(&descriptor, &path) + .expect_err("invalid model schema"); + assert!(matches!( + error, + FabricError::InvalidAdapterDescriptor { + path: error_path, + message, + } if error_path == path && message.contains(expected) + )); + } + } + #[test] fn adapter_workflow_schema_must_be_valid_and_allow_objects() { let path = repository_root().join("adapters/claude/fabric-adapter.json"); diff --git a/crates/fabric-core/src/schema.rs b/crates/fabric-core/src/schema.rs index 3c9a045ed..dae592ee2 100644 --- a/crates/fabric-core/src/schema.rs +++ b/crates/fabric-core/src/schema.rs @@ -320,6 +320,10 @@ mod tests { schema["properties"]["settings_schema"]["type"], serde_json::json!(["object", "null"]) ); + assert_eq!( + schema["properties"]["model_schema"]["type"], + serde_json::json!(["object", "null"]) + ); assert_eq!( schema["properties"]["workflow_schema"]["type"], serde_json::json!(["object", "null"]) diff --git a/docs/adapter-contract/adapter-descriptor.md b/docs/adapter-contract/adapter-descriptor.md index eb5f5e962..6efde7707 100644 --- a/docs/adapter-contract/adapter-descriptor.md +++ b/docs/adapter-contract/adapter-descriptor.md @@ -41,6 +41,7 @@ for exact wire values. | Field | Validates | | --- | --- | | `settings_schema` | `FabricConfig.harness.settings` | +| `model_schema` | Every entry in `FabricConfig.models` after excluding separately validated extensions | | `workflow_schema` | The complete `FabricConfig.workflow` block | | `tool_definition_schema` | Every entry in `FabricConfig.tools.definitions` | | `extension_schemas` | Named `extensions` maps at southbound extension points | @@ -54,7 +55,9 @@ settings are an intentional compatibility surface. If an adapter does not support settings, publish a closed empty `settings_schema`. A configured workflow fails planning when the descriptor does not publish `workflow_schema`. Named tool definitions similarly fail when -`tool_definition_schema` is absent. +`tool_definition_schema` is absent. An omitted `model_schema` preserves dynamic +model-provider behavior; when present, the schema is applied to every model role +and its failures are reported consistently by planning and `doctor(...)`. ## Minimal Python Descriptor diff --git a/docs/adapter-contract/normalized-configuration.md b/docs/adapter-contract/normalized-configuration.md index 01084c828..dea729184 100644 --- a/docs/adapter-contract/normalized-configuration.md +++ b/docs/adapter-contract/normalized-configuration.md @@ -54,6 +54,8 @@ The descriptor controls the projection: - Scalar normalized fields are included only when `config.accepts` declares that the adapter can apply them. +- Every configured model role is validated against `model_schema` when the + selected descriptor publishes one. - Resolved native skills and MCP servers come from the capability plan. - `harness.settings` and a configured `workflow` are validated against the selected descriptor before startup. @@ -69,6 +71,11 @@ An absent optional field preserves the adapter target's default where the field's contract says so. For example, `tools.enabled: null` preserves the target default, while an empty list explicitly selects no named tools. +`model_schema` uses the same self-contained JSON Schema vocabulary as the other +descriptor schemas. Use it for statically knowable provider compatibility and +closed `ModelConfig.settings` validation. Do not use it for credential checks, +provider reachability, or model availability; those remain startup concerns. + ## Extensions Every extensible southbound block has a named `extensions` map. An adapter diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterdescriptor.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterdescriptor.mdx index b87a11091..bcc120360 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterdescriptor.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-adapterdescriptor.mdx @@ -9,7 +9,7 @@ SPDX-License-Identifier: Apache-2.0 */} Generated from `cargo doc --no-deps -p nemo-fabric-core`. -
String,\n    pub adapter_id: String,\n    pub harness: String,\n    pub adapter_kind: AdapterKind,\n    pub runner: Map<String, Value>,\n    pub settings_schema: Option<Map<String, Value>>,\n    pub workflow_schema: Option<Map<String, Value>>,\n    pub tool_definition_schema: Option<Map<String, Value>>,\n    pub extension_schemas: BTreeMap<AdapterExtensionPoint, Map<String, Value>>,\n    pub requirements: AdapterRequirements,\n    pub config: AdapterConfigSupport,\n    pub telemetry: AdapterTelemetrySupport,\n    pub capabilities: RuntimeCapabilities,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+
String,\n    pub adapter_id: String,\n    pub harness: String,\n    pub adapter_kind: AdapterKind,\n    pub runner: Map<String, Value>,\n    pub settings_schema: Option<Map<String, Value>>,\n    pub model_schema: Option<Map<String, Value>>,\n    pub workflow_schema: Option<Map<String, Value>>,\n    pub tool_definition_schema: Option<Map<String, Value>>,\n    pub extension_schemas: BTreeMap<AdapterExtensionPoint, Map<String, Value>>,\n    pub requirements: AdapterRequirements,\n    pub config: AdapterConfigSupport,\n    pub telemetry: AdapterTelemetrySupport,\n    pub capabilities: RuntimeCapabilities,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
Language-neutral adapter descriptor for a harness integration. @@ -39,6 +39,10 @@ Generic runner defaults consumed by the selected runtime adapter. JSON Schema for adapter-owned `harness.settings`. +### `model_schema: Option>` + +JSON Schema applied to every normalized `FabricConfig.models` entry. + ### `workflow_schema: Option>` JSON Schema for adapter-owned `FabricConfig.workflow`. diff --git a/docs/sdk/python.mdx b/docs/sdk/python.mdx index 2fa8de2ca..63feddb8f 100644 --- a/docs/sdk/python.mdx +++ b/docs/sdk/python.mdx @@ -203,6 +203,12 @@ by adapter: Model selection is deterministic: the `default` role wins; otherwise a single named role is selected. More than one role without `default` fails planning. +Claude and Codex publish a descriptor-owned `model_schema` for every configured +model role. Their native providers (`anthropic` and `openai`, respectively) +keep the existing authentication path. Other providers remain valid only with +an explicit `base_url` and `api_key_env`. The same schema rejects undeclared +`ModelConfig.settings` during planning and reports each issue through +`doctor(...)` before adapter startup. If a normalized field has no complete mapping, `plan(...)` and runtime start fail with a configuration compatibility error naming the adapter and field. diff --git a/examples/harbor/swebench/adapters/claude/fabric-adapter.json b/examples/harbor/swebench/adapters/claude/fabric-adapter.json index d04516c6d..da212d390 100644 --- a/examples/harbor/swebench/adapters/claude/fabric-adapter.json +++ b/examples/harbor/swebench/adapters/claude/fabric-adapter.json @@ -6,6 +6,31 @@ "runner": { "module": "nemo_fabric_adapters.claude.adapter" }, + "model_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "provider": {"type": "string", "minLength": 1}, + "model": {"type": "string", "minLength": 1}, + "temperature": {"type": "number"}, + "api_key_env": {"type": "string", "minLength": 1}, + "base_url": {"type": "string", "minLength": 1}, + "settings": { + "type": "object", + "properties": {}, + "additionalProperties": false + } + }, + "required": ["provider", "model"], + "if": { + "properties": {"provider": {"const": "anthropic"}}, + "required": ["provider"] + }, + "else": { + "required": ["base_url", "api_key_env"] + }, + "additionalProperties": false + }, "settings_schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", diff --git a/schemas/adapter-contract/adapter-descriptor.schema.json b/schemas/adapter-contract/adapter-descriptor.schema.json index 0471bb1b3..682b784e0 100644 --- a/schemas/adapter-contract/adapter-descriptor.schema.json +++ b/schemas/adapter-contract/adapter-descriptor.schema.json @@ -305,6 +305,14 @@ "minLength": 1, "type": "string" }, + "model_schema": { + "additionalProperties": true, + "description": "JSON Schema applied to every normalized `FabricConfig.models` entry.", + "type": [ + "object", + "null" + ] + }, "requirements": { "$ref": "#/$defs/AdapterRequirements", "default": {}, diff --git a/schemas/run-plan.schema.json b/schemas/run-plan.schema.json index 0b1b9f1f3..a97f1e77d 100644 --- a/schemas/run-plan.schema.json +++ b/schemas/run-plan.schema.json @@ -171,6 +171,14 @@ "minLength": 1, "type": "string" }, + "model_schema": { + "additionalProperties": true, + "description": "JSON Schema applied to every normalized `FabricConfig.models` entry.", + "type": [ + "object", + "null" + ] + }, "requirements": { "$ref": "#/$defs/AdapterRequirements", "default": {}, diff --git a/skills/nemo-fabric-build-adapter/SKILL.md b/skills/nemo-fabric-build-adapter/SKILL.md index 3ec83e6c6..af38a8c74 100644 --- a/skills/nemo-fabric-build-adapter/SKILL.md +++ b/skills/nemo-fabric-build-adapter/SKILL.md @@ -51,8 +51,10 @@ translation: target `harness`, `adapter_kind`, and runner binding. - Set `config.input` to `agent_config` for a new adapter. - Declare only normalized `config.accepts` fields the implementation enforces. -- Publish closed `settings_schema`, `workflow_schema`, - `tool_definition_schema`, and `extension_schemas` where applicable. +- Publish closed `settings_schema`, `model_schema`, `workflow_schema`, + `tool_definition_schema`, and `extension_schemas` where applicable. Use + `model_schema` only for static model/provider compatibility and model settings; + keep credential validity and provider availability in startup validation. - Declare runtime requirements and telemetry outputs without secret values. - Leave optional capability flags false unless the installed NeMo Fabric runtime exposes and tests that adapter operation. Relay-backed ATOF streaming does diff --git a/skills/nemo-fabric-integrate/references/config-mapping.md b/skills/nemo-fabric-integrate/references/config-mapping.md index 0764604fe..e2d7ee469 100644 --- a/skills/nemo-fabric-integrate/references/config-mapping.md +++ b/skills/nemo-fabric-integrate/references/config-mapping.md @@ -34,6 +34,11 @@ indexes the public config models. The generated pages omit constructor fields an defaults, so read the installed `nemo_fabric` models (they ship `py.typed`) for exact field names and defaults. +Claude and Codex validate every model role against their descriptor-owned +`model_schema`. Provider identifiers outside their native `anthropic` and +`openai` paths require both `ModelConfig.base_url` and +`ModelConfig.api_key_env`; undeclared `ModelConfig.settings` also fail planning. + ## Build And Shape Construct the nested config directly, then adjust capabilities with helper diff --git a/tests/adapters/test_claude_adapter.py b/tests/adapters/test_claude_adapter.py index 64b16c9be..cf5f21ed0 100644 --- a/tests/adapters/test_claude_adapter.py +++ b/tests/adapters/test_claude_adapter.py @@ -86,6 +86,31 @@ def test_claude_descriptor_is_narrow_and_versioned(): "runner": { "module": "nemo_fabric_adapters.claude.adapter", }, + "model_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "provider": {"type": "string", "minLength": 1}, + "model": {"type": "string", "minLength": 1}, + "temperature": {"type": "number"}, + "api_key_env": {"type": "string", "minLength": 1}, + "base_url": {"type": "string", "minLength": 1}, + "settings": { + "type": "object", + "properties": {}, + "additionalProperties": False, + }, + }, + "required": ["provider", "model"], + "if": { + "properties": {"provider": {"const": "anthropic"}}, + "required": ["provider"], + }, + "else": { + "required": ["base_url", "api_key_env"], + }, + "additionalProperties": False, + }, "settings_schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", diff --git a/tests/adapters/test_codex_adapter.py b/tests/adapters/test_codex_adapter.py index f131b98a9..e09d6d1f6 100644 --- a/tests/adapters/test_codex_adapter.py +++ b/tests/adapters/test_codex_adapter.py @@ -1118,6 +1118,18 @@ def test_descriptor_has_no_codex_binary_requirement(): "mcp", "skills", ] + assert descriptor["model_schema"]["if"]["properties"]["provider"] == { + "const": "openai" + } + assert descriptor["model_schema"]["else"]["required"] == [ + "base_url", + "api_key_env", + ] + assert descriptor["model_schema"]["properties"]["settings"] == { + "type": "object", + "properties": {}, + "additionalProperties": False, + } assert "requirements" not in descriptor diff --git a/tests/python/test_native_sdk.py b/tests/python/test_native_sdk.py index 8b4339917..1533b281a 100644 --- a/tests/python/test_native_sdk.py +++ b/tests/python/test_native_sdk.py @@ -68,6 +68,8 @@ def test_native_run_rejects_multiple_request_sources(hermes_shim_agent_dir: Path def test_plan_rejects_adapter_incompatible_normalized_tool_policy(): config = base_config() config.harness.adapter_id = "nvidia.fabric.codex" + config.models["default"].provider = "openai" + config.models["default"].model = "openai/gpt-5.4" config.models["default"].temperature = None config.block_tools("Bash") diff --git a/tests/python/test_typed_config.py b/tests/python/test_typed_config.py index 74c22e520..d381d3188 100644 --- a/tests/python/test_typed_config.py +++ b/tests/python/test_typed_config.py @@ -162,6 +162,99 @@ async def diagnoses_adapter_incompatibility_without_weakening_plan(client: Fabri ) +def _model_provider_config(adapter_id: str, provider: str) -> FabricConfig: + """Build a repository-backed config for model compatibility tests.""" + + config = _repository_adapter_config().to_mapping() + config["harness"]["adapter_id"] = adapter_id + config["models"]["default"] = { + "provider": provider, + "model": "test-model", + } + return FabricConfig.from_mapping(config) + + +@pytest.mark.parametrize( + ("adapter_id", "provider"), + [ + ("nvidia.fabric.claude", "openai"), + ("nvidia.fabric.codex", "anthropic"), + ], +) +async def test_plan_and_doctor_require_custom_provider_connection( + adapter_id: str, + provider: str, +): + config = _model_provider_config(adapter_id, provider) + + with pytest.raises(FabricConfigError, match=r"models\.default\.base_url"): + Fabric().plan(config, base_dir=ROOT) + + report = await Fabric().doctor(config, base_dir=ROOT) + + assert report.status == "fail" + assert any( + check.name == "config.unsupported" + and check.metadata.get("field") == "models.default.base_url" + for check in report.checks + ) + assert any( + check.name == "config.unsupported" + and check.metadata.get("field") == "models.default.api_key_env" + for check in report.checks + ) + + +@pytest.mark.parametrize( + ("adapter_id", "provider"), + [ + ("nvidia.fabric.claude", "anthropic"), + ("nvidia.fabric.codex", "openai"), + ], +) +def test_plan_accepts_native_model_provider(adapter_id: str, provider: str): + config = _model_provider_config(adapter_id, provider) + + plan = Fabric().plan(config, base_dir=ROOT) + + assert plan.config.models["default"]["provider"] == provider + + +@pytest.mark.parametrize( + "adapter_id", + ["nvidia.fabric.claude", "nvidia.fabric.codex"], +) +def test_plan_accepts_explicit_custom_provider_connection(adapter_id: str): + config = _model_provider_config(adapter_id, "acme") + config.models["default"].base_url = "https://models.example/v1" + config.models["default"].api_key_env = "ACME_API_KEY" + + plan = Fabric().plan(config, base_dir=ROOT) + + assert plan.config.models["default"]["provider"] == "acme" + + +async def test_plan_and_doctor_reject_undeclared_model_setting(): + config = _model_provider_config("nvidia.fabric.claude", "anthropic") + config.models["default"].settings["api_timeout"] = 30 + + with pytest.raises( + FabricConfigError, + match=r"models\.default\.settings\.api_timeout", + ): + Fabric().plan(config, base_dir=ROOT) + + report = await Fabric().doctor(config, base_dir=ROOT) + + assert report.status == "fail" + assert any( + check.name == "config.unsupported" + and check.metadata.get("field") + == "models.default.settings.api_timeout" + for check in report.checks + ) + + async def test_typed_config(): client = Fabric() await resolves_and_diagnoses_typed_config(client)