Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions adapters/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
25 changes: 25 additions & 0 deletions adapters/claude/fabric-adapter.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
25 changes: 25 additions & 0 deletions adapters/codex/fabric-adapter.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
25 changes: 25 additions & 0 deletions crates/fabric-cli/assets/adapters/claude/fabric-adapter.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
25 changes: 25 additions & 0 deletions crates/fabric-cli/assets/adapters/codex/fabric-adapter.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
165 changes: 165 additions & 0 deletions crates/fabric-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<serde_json::Map<String, Value>>,
/// JSON Schema applied to every normalized `FabricConfig.models` entry.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_schema: Option<serde_json::Map<String, Value>>,
/// JSON Schema for adapter-owned `FabricConfig.workflow`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workflow_schema: Option<serde_json::Map<String, Value>>,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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::<BTreeSet<_>>();

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";
Expand Down Expand Up @@ -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");
Expand Down
4 changes: 4 additions & 0 deletions crates/fabric-core/src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
5 changes: 4 additions & 1 deletion docs/adapter-contract/adapter-descriptor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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

Expand Down
7 changes: 7 additions & 0 deletions docs/adapter-contract/normalized-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
Loading
Loading