diff --git a/apps/dashboard/lib/gateway-model-compatibility.test.ts b/apps/dashboard/lib/gateway-model-compatibility.test.ts new file mode 100644 index 0000000..4969360 --- /dev/null +++ b/apps/dashboard/lib/gateway-model-compatibility.test.ts @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { gatewayModelIssue, type GatewayModelCompatibilityInput } from "./gateway-model-compatibility.ts"; + +const base: GatewayModelCompatibilityInput = { + state: "available", + discoverySupported: true, + exposure: "catalog", + selected: false, + selectedAssigned: true, +}; + +test("available full catalogs have no issue", () => assert.equal(gatewayModelIssue(base), null)); +test("classifies drift and selected-model failures", () => { + assert.equal(gatewayModelIssue({ ...base, state: "changed" })?.code, "model_changed"); + assert.equal(gatewayModelIssue({ ...base, state: "removed" })?.code, "model_removed"); + assert.equal(gatewayModelIssue({ ...base, selected: true, selectedAssigned: false })?.code, "invalid_selected_model"); +}); +test("outages remain unknown instead of removed", () => { + assert.equal(gatewayModelIssue({ ...base, discoverySupported: false })?.code, "discovery_unsupported"); + assert.equal(gatewayModelIssue({ ...base, refreshError: "timeout" })?.code, "refresh_failed"); + assert.equal(gatewayModelIssue({ ...base, stale: true })?.code, "catalog_stale"); +}); +test("reports selected-only harness exposure", () => { + assert.equal(gatewayModelIssue({ ...base, exposure: "selected_only" })?.code, "selected_only"); +}); diff --git a/apps/dashboard/lib/gateway-model-compatibility.ts b/apps/dashboard/lib/gateway-model-compatibility.ts new file mode 100644 index 0000000..b6c350b --- /dev/null +++ b/apps/dashboard/lib/gateway-model-compatibility.ts @@ -0,0 +1,51 @@ +export type GatewayModelState = "available" | "changed" | "removed" | "unknown"; + +export type GatewayModelCompatibilityInput = { + state: GatewayModelState; + discoverySupported: boolean | null; + refreshError?: string | null; + stale?: boolean; + exposure: "catalog" | "selected_only"; + selected: boolean; + selectedAssigned: boolean; +}; + +export type GatewayModelIssue = { + severity: "warning" | "error"; + code: + | "discovery_unsupported" + | "refresh_failed" + | "catalog_stale" + | "model_changed" + | "model_removed" + | "selected_only" + | "invalid_selected_model"; + message: string; +}; + +export function gatewayModelIssue( + input: GatewayModelCompatibilityInput, +): GatewayModelIssue | null { + if (input.selected && !input.selectedAssigned) { + return { severity: "error", code: "invalid_selected_model", message: "The selected model is not assigned to this harness." }; + } + if (input.discoverySupported === false) { + return { severity: "warning", code: "discovery_unsupported", message: "This gateway provisioner does not support model discovery." }; + } + if (input.refreshError) { + return { severity: "warning", code: "refresh_failed", message: "The latest refresh failed; showing the last-known catalog." }; + } + if (input.stale) { + return { severity: "warning", code: "catalog_stale", message: "The gateway model catalog is stale." }; + } + if (input.state === "changed") { + return { severity: input.selected ? "error" : "warning", code: "model_changed", message: "The gateway reports materially changed model metadata." }; + } + if (input.state === "removed") { + return { severity: input.selected ? "error" : "warning", code: "model_removed", message: "The model is absent from the latest successful refresh." }; + } + if (input.exposure === "selected_only") { + return { severity: "warning", code: "selected_only", message: "This harness exposes only its selected gateway model." }; + } + return null; +} diff --git a/apps/dashboard/lib/harness-metadata.ts b/apps/dashboard/lib/harness-metadata.ts index 7647c07..0f9da9b 100644 --- a/apps/dashboard/lib/harness-metadata.ts +++ b/apps/dashboard/lib/harness-metadata.ts @@ -5,6 +5,7 @@ export type HarnessMetadata = { description: string; binary_names: string[]; install_command_template: string; + gateway_model_exposure: "catalog" | "selected_only"; capabilities: string[]; component_rules: { agents_require_plugin: boolean; diff --git a/apps/dashboard/lib/mapping-compatibility.test.ts b/apps/dashboard/lib/mapping-compatibility.test.ts index 385b46e..cadf227 100644 --- a/apps/dashboard/lib/mapping-compatibility.test.ts +++ b/apps/dashboard/lib/mapping-compatibility.test.ts @@ -25,6 +25,7 @@ const claude: HarnessMetadata = { description: "", binary_names: ["claude"], install_command_template: "", + gateway_model_exposure: "selected_only", capabilities: [], component_rules: openRules, generations: [ diff --git a/apps/docs/next/development/gateway-adapter-architecture.mdx b/apps/docs/next/development/gateway-adapter-architecture.mdx index 2754de6..72a6a5e 100644 --- a/apps/docs/next/development/gateway-adapter-architecture.mdx +++ b/apps/docs/next/development/gateway-adapter-architecture.mdx @@ -52,7 +52,7 @@ agent request with session-bound inference JWT | `gh-gateway::GatewayAdapter` | Registry identity, client route validation, upstream path/auth policy, and invalid-credential classification | Network I/O, harness files, or credential bytes | | `HarnessImplementation` | Version-specific inference-token placement and agent wire protocol | Gateway-type dispatch or upstream-provider behavior | | Inference proxy | Streaming, limits, credential resolution, request forwarding, and application of adapter decisions | Provider administrator operations | -| `GatewayProvisioner` | Creating, retaining, rotating, and revoking upstream user credentials | Agent configuration or inference forwarding | +| `GatewayProvisioner` | Creating, retaining, rotating, and revoking upstream user credentials; asynchronously discovering models | Agent configuration or inference forwarding | | Control API | Server-selected gateway mode, policy personalization, encrypted credentials, and startup validation | Enabling gateway mode from client input | `gateway.type` selects the compiled adapter. `gateway.provisioner.type` selects @@ -83,6 +83,13 @@ dispatcher attaches the harness-owned `AuthPlacement` and `wire_api` afterward, so a gateway cannot replace those decisions. Route and wiring debug output redacts the token. +Model discovery deliberately does not belong to this trait. The Control API +calls the asynchronous `GatewayProvisioner::list_models` capability, persists +the last successful snapshot, and distinguishes unsupported or failed refreshes +from a successful snapshot that removed a model. Custom executable +provisioners may opt into the `list_models` protocol operation; older ones +report discovery as unsupported. + `UpstreamCredentialPlacement` declares bearer authentication or a named header; it never contains the credential itself. The proxy strips `Authorization`, `X-API-Key`, and the adapter-declared credential header before applying exactly diff --git a/apps/docs/next/reference/governance-config.mdx b/apps/docs/next/reference/governance-config.mdx index 4d612b4..3137bdc 100644 --- a/apps/docs/next/reference/governance-config.mdx +++ b/apps/docs/next/reference/governance-config.mdx @@ -8,8 +8,8 @@ This is the complete policy delivered to clients. The mounted deployment YAML su ```yaml revision: "organization-managed" -contract_version: 3 -required_capabilities: [adapter_intervals, compiled_harness_registry, transactional_reconcile, versioned_state, gateway_inference_jwt] +contract_version: 4 +required_capabilities: [adapter_intervals, compiled_harness_registry, transactional_reconcile, versioned_state, gateway_inference_jwt, gateway_model_catalog] ttl_seconds: 300 required: true allowed_harnesses: [codex, claude, kimi, opencode] @@ -48,6 +48,7 @@ packages: harnesses: codex: version_requirement: ">=0.149.0, <0.150.0" + gateway_models: [gpt-5.6-sol, gpt-5.6-terra] package_overrides: organization-toolkit: enabled: true @@ -86,7 +87,15 @@ harnesses: ## Harness policy -`managed_config` contains model and harness-specific settings. `mcp` is additive. `package_overrides` may enable or disable an organization package and provide adapter settings; it cannot replace the source, version, or digest. `minimum_client_version` remains visible to operators, while `required_capabilities` is the enforcement mechanism for contract features. +`managed_config` contains the selected model and harness-specific settings. In +gateway mode, `gateway_models` contains the exact upstream IDs assigned to that +harness and the selected `managed_config.model` must be a member. OpenCode and +Kimi receive the full catalog; Codex and Claude support only the selected model. +The field is ignored in direct mode, so native model catalogs remain untouched. +`mcp` is additive. `package_overrides` may enable or disable an organization +package and provide adapter settings; it cannot replace the source, version, or +digest. `minimum_client_version` remains visible to operators, while +`required_capabilities` is the enforcement mechanism for contract features. Managed Codex launches default to `approval_policy: on-request`, preserving native approval prompts while the configured sandbox remains active. An explicit `approval_policy` remains authoritative. The provider-neutral `auto_approve: true` selects `never` only when no explicit Codex approval policy is present; `auto_approve: false` and an absent value both select `on-request`. @@ -100,7 +109,12 @@ accepted risk. The presence of top-level `gateway` enables gateway mode for every allowed coding agent and connects Blue to an upstream gateway operated by your organization. Blue does not bundle that gateway. `litellm` is the first and currently only supported type. The Control API injects `proxy_url` and the authenticated user's session-bound inference `token`; each agent inherits its model from its own `managed_config`, or leaves model selection to the agent when none is managed. `auth_style` remains on the wire, but `bearer` is the only supported client-to-proxy value. -OpenCode is the exception to leaving model selection to the agent. Its governed runtime config merges over the user's own, so an unpinned model would survive next to the governed provider it no longer resolves against. When OpenCode has no managed model, Blue keeps the agent's native model if the gateway serves it, otherwise pins a governed default, and warns at launch so the gap stays visible. Set `managed_config.available_models` to the list your gateway actually serves to control that catalog. +Gateway model catalogs are discovered server-side and retained as last-known +state. A failed discovery does not make models appear removed. Administrator +mappings are revisioned governance policy and are never silently renamed, +substituted, or deleted when discovery reports drift. The former OpenCode-only +`managed_config.available_models` escape hatch is deprecated; migrate those +values to the typed harness `gateway_models` field. ## Managed packages diff --git a/apps/docs/openapi/next.yaml b/apps/docs/openapi/next.yaml index f973c2b..45a453c 100644 --- a/apps/docs/openapi/next.yaml +++ b/apps/docs/openapi/next.yaml @@ -1765,6 +1765,11 @@ components: properties: version_requirement: { type: string, description: Semver range required for the locally installed harness } allow_unverified_versions: { type: boolean, default: false, description: Permit matching releases beyond Blue's certified ceiling; requires version_requirement } + gateway_models: + type: array + uniqueItems: true + description: Exact gateway model IDs assigned to this harness; ignored in direct mode. + items: { type: string, minLength: 1 } managed_config: { $ref: "#/components/schemas/ManagedConfig" } mcp: type: array diff --git a/crates/gh-config/src/adapters/claude/writer.rs b/crates/gh-config/src/adapters/claude/writer.rs index f05d2e9..2073f1d 100644 --- a/crates/gh-config/src/adapters/claude/writer.rs +++ b/crates/gh-config/src/adapters/claude/writer.rs @@ -20,6 +20,16 @@ pub fn write( session_start_hooks: Option, _enforced: bool, ) -> Result { + if wiring.is_some() + && (policy.gateway_models.is_empty() + || policy.managed_config.model.as_ref().is_some_and(|selected| { + !policy.gateway_models.iter().any(|model| model == selected) + })) + { + return Err(GhError::config( + "gateway-mode Claude selected model must be present in gateway_models", + )); + } migrate_legacy_global_config(plan, home, policy)?; let runtime = crate::managed_runtime_dir(home).join("claude"); let settings_path = runtime.join("settings.json"); diff --git a/crates/gh-config/src/adapters/codex/writer.rs b/crates/gh-config/src/adapters/codex/writer.rs index e5e30c2..816d513 100644 --- a/crates/gh-config/src/adapters/codex/writer.rs +++ b/crates/gh-config/src/adapters/codex/writer.rs @@ -41,6 +41,16 @@ pub fn write( session_upload_hook: Option, session_start_hook: Option, ) -> Result { + if wiring.is_some() + && (policy.gateway_models.is_empty() + || policy.managed_config.model.as_ref().is_some_and(|selected| { + !policy.gateway_models.iter().any(|model| model == selected) + })) + { + return Err(GhError::config( + "gateway-mode Codex selected model must be present in gateway_models", + )); + } let base_path = home.join(".codex").join("config.toml"); migrate_legacy_global_config(plan, &base_path, policy)?; let base = plan.read_toml_table(&base_path)?; diff --git a/crates/gh-config/src/adapters/kimi/writer.rs b/crates/gh-config/src/adapters/kimi/writer.rs index 6e0f90d..aac65b1 100644 --- a/crates/gh-config/src/adapters/kimi/writer.rs +++ b/crates/gh-config/src/adapters/kimi/writer.rs @@ -40,12 +40,33 @@ pub fn write( table.remove("default_yolo"); } if let Some(w) = wiring { - let effective_model = policy.managed_config.model.clone().or_else(|| { - table - .get("default_model") - .and_then(Toml::as_str) - .map(str::to_owned) - }); + let mut catalog = policy + .gateway_models + .iter() + .map(|model| model.trim()) + .filter(|model| !model.is_empty()) + .map(str::to_owned) + .collect::>(); + catalog.sort(); + catalog.dedup(); + if catalog.is_empty() { + return Err(GhError::config( + "gateway-mode Kimi policy requires at least one gateway_models entry", + )); + } + let effective_model = policy + .managed_config + .model + .clone() + .or_else(|| catalog.first().cloned()); + if effective_model + .as_ref() + .is_some_and(|selected| !catalog.iter().any(|model| model == selected)) + { + return Err(GhError::config( + "Kimi selected model must be present in gateway_models", + )); + } // kimi-code resolves the wire transport from the provider `type` // (`openai` vs `openai_responses`). The model-alias `protocol` field // only accepts the literal "anthropic": kimi-code 0.20.x–0.31.x hard- @@ -59,8 +80,8 @@ pub fn write( Some("responses" | "openai_responses") => "openai_responses", _ => "openai", }; - // Point the selected model at the governed provider. - if let Some(model) = effective_model { + // Point every assigned model at the governed provider. + for model in &catalog { let mut model_entry = toml::map::Map::new(); model_entry.insert("provider".into(), Toml::String(GOVERNED_PROVIDER.into())); model_entry.insert("model".into(), Toml::String(model.clone())); @@ -72,7 +93,12 @@ pub fn write( .filter(|value| *value > 0) .unwrap_or(262_144); model_entry.insert("max_context_size".into(), Toml::Integer(max_context_size)); - upsert_subtable(&mut table, "models", model, Toml::Table(model_entry)); + upsert_subtable(&mut table, "models", model.clone(), Toml::Table(model_entry)); + } + if policy.managed_config.model.is_none() { + if let Some(model) = effective_model { + table.insert("default_model".into(), Toml::String(model)); + } } let mut provider = toml::map::Map::new(); @@ -395,7 +421,11 @@ mod tests { auth: AuthPlacement::InFile, }; - test_write(&home, &HarnessPolicy::default(), Some(&wiring), None).unwrap(); + let policy = HarnessPolicy { + gateway_models: vec!["personal-default".into()], + ..HarnessPolicy::default() + }; + test_write(&home, &policy, Some(&wiring), None).unwrap(); let config = std::fs::read_to_string(home.join(".config/blue/runtime/kimi/config.toml")) .unwrap() .parse::() diff --git a/crates/gh-config/src/adapters/mod.rs b/crates/gh-config/src/adapters/mod.rs index d7384b0..e73dd78 100644 --- a/crates/gh-config/src/adapters/mod.rs +++ b/crates/gh-config/src/adapters/mod.rs @@ -60,6 +60,13 @@ pub struct GenerationSupport { pub component_rules: ComponentRules, } +#[derive(Debug, Clone, Copy, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum GatewayModelExposure { + Catalog, + SelectedOnly, +} + #[derive(Debug, serde::Serialize)] pub struct PublicHarnessMetadata { pub key: &'static str, @@ -68,6 +75,7 @@ pub struct PublicHarnessMetadata { pub description: &'static str, pub binary_names: &'static [&'static str], pub install_command_template: &'static str, + pub gateway_model_exposure: GatewayModelExposure, /// Backward-compatible summary derived from every generation. pub capabilities: Vec<&'static str>, /// Backward-compatible current-generation rules. Consumers should prefer @@ -494,6 +502,10 @@ pub fn registry_metadata() -> Vec { description: metadata.description, binary_names: metadata.binary_names, install_command_template: metadata.install_command_template, + gateway_model_exposure: match metadata.key { + "opencode" | "kimi" => GatewayModelExposure::Catalog, + _ => GatewayModelExposure::SelectedOnly, + }, capabilities, component_rules: current_support.component_rules, generations: definition diff --git a/crates/gh-config/src/adapters/opencode/writer.rs b/crates/gh-config/src/adapters/opencode/writer.rs index 9f5ecdd..6b64dc8 100644 --- a/crates/gh-config/src/adapters/opencode/writer.rs +++ b/crates/gh-config/src/adapters/opencode/writer.rs @@ -13,7 +13,6 @@ use crate::HarnessWrite; const GOVERNED_PROVIDER: &str = "governed"; const GATEWAY_TOKEN_ENV: &str = "BLUE_OPENCODE_GATEWAY_TOKEN"; -const ADDITIONAL_GOVERNED_MODELS: &[&str] = &["openai/gpt-5.6-sol"]; const SCHEMA: &str = "https://opencode.ai/config.json"; pub fn write( @@ -32,6 +31,22 @@ pub fn write( root.insert("$schema".to_string(), json!(SCHEMA)); let catalog = governed_catalog(policy); + if wiring.is_some() && catalog.is_empty() { + return Err(GhError::config( + "gateway-mode OpenCode policy requires at least one gateway_models entry", + )); + } + if wiring.is_some() + && policy + .managed_config + .model + .as_ref() + .is_some_and(|selected| !catalog.iter().any(|model| model == selected)) + { + return Err(GhError::config( + "OpenCode selected model must be present in gateway_models", + )); + } let mut warnings = Vec::new(); // In gateway mode the model is always pinned: `OPENCODE_CONFIG_CONTENT` // merges over the user's global config, so leaving `model` unset lets a @@ -140,33 +155,19 @@ pub fn write( }) } -/// Provider-local model IDs the governed provider advertises: an -/// admin-supplied `managed_config.available_models` when present (the gateway -/// is the only thing that knows what it actually serves), else the built-in -/// default. Strip one optional `governed/` prefix and ignore empty IDs. -/// Never empty, so the provider block is always renderable. +/// Exact provider-local IDs assigned by the control plane. Strip one optional +/// legacy `governed/` prefix and normalize duplicates defensively. fn governed_catalog(policy: &HarnessPolicy) -> Vec { - let configured = policy - .managed_config - .extra - .get("available_models") - .and_then(Value::as_array) - .map(|models| { - models - .iter() - .filter_map(Value::as_str) - .map(|model| model.strip_prefix("governed/").unwrap_or(model)) - .filter(|model| !model.is_empty()) - .map(str::to_owned) - .collect::>() - }) - .filter(|models| !models.is_empty()); - configured.unwrap_or_else(|| { - ADDITIONAL_GOVERNED_MODELS - .iter() - .map(|model| (*model).to_owned()) - .collect() - }) + let mut models = policy + .gateway_models + .iter() + .map(|model| model.strip_prefix("governed/").unwrap_or(model).trim()) + .filter(|model| !model.is_empty()) + .map(str::to_owned) + .collect::>(); + let mut seen = std::collections::BTreeSet::new(); + models.retain(|model| seen.insert(model.clone())); + models } /// The user's native model, but only when the gateway is known to serve it. @@ -437,7 +438,8 @@ mod tests { fn gateway_provider_has_runtime_models_and_native_auth_shape() { let home = std::env::temp_dir().join(format!("gh-opencode-gateway-{}", std::process::id())); let policy: HarnessPolicy = serde_json::from_value(json!({ - "managed_config": { "model": "e2e/model" } + "managed_config": { "model": "e2e/model" }, + "gateway_models": ["e2e/model", "openai/gpt-5.6-sol"] })) .unwrap(); let wiring = gh_gateway::GatewayWiring { @@ -517,8 +519,16 @@ mod tests { std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(&path, serde_json::to_vec(&json!({ "model": model })).unwrap()).unwrap(); } - let policy: HarnessPolicy = - serde_json::from_value(json!({ "managed_config": managed_config })).unwrap(); + let mut managed_config = managed_config; + let gateway_models = managed_config + .as_object_mut() + .and_then(|config| config.remove("available_models")) + .unwrap_or_else(|| json!(["openai/gpt-5.6-sol"])); + let policy: HarnessPolicy = serde_json::from_value(json!({ + "managed_config": managed_config, + "gateway_models": gateway_models + })) + .unwrap(); let report = test_write(&home, &policy, Some(&gateway_wiring()), None).unwrap(); let managed: Value = serde_json::from_slice( &std::fs::read(home.join(".config/blue/runtime/opencode/opencode.json")).unwrap(), @@ -621,11 +631,11 @@ mod tests { } #[test] - fn policy_catalog_discards_empty_and_non_string_entries() { + fn policy_catalog_discards_empty_entries() { let (_, managed) = gateway_write( "gateway-mixed-catalog", None, - json!({ "available_models": ["", "governed/", null, 42, "governed/org/fast", "org/slow"] }), + json!({ "available_models": ["", "governed/", "governed/org/fast", "org/slow"] }), ); assert_eq!(managed["model"], "governed/org/fast"); assert_eq!( @@ -635,30 +645,17 @@ mod tests { "org/slow": { "name": "org/slow" } }) ); - - let (_, fallback) = gateway_write( - "gateway-invalid-catalog", - None, - json!({ "available_models": ["", "governed/", null, 42] }), - ); - assert_eq!(fallback["model"], "governed/openai/gpt-5.6-sol"); - assert_eq!( - fallback["provider"]["governed"]["models"], - json!({ "openai/gpt-5.6-sol": { "name": "openai/gpt-5.6-sol" } }) - ); } #[test] fn policy_catalog_strips_one_prefix_and_preserves_order_and_duplicates() { let policy: HarnessPolicy = serde_json::from_value(json!({ - "managed_config": { - "available_models": ["governed/org/first", "governed/governed/legacy", "org/first"] - } + "gateway_models": ["governed/org/first", "governed/governed/legacy", "org/first"] })) .unwrap(); assert_eq!( governed_catalog(&policy), - ["org/first", "governed/legacy", "org/first"] + ["org/first", "governed/legacy"] ); } @@ -667,7 +664,7 @@ mod tests { let (report, managed) = gateway_write( "gateway-managed", Some("openai/gpt-5.6-sol"), - json!({ "model": "org/pinned", "available_models": ["org/fast"] }), + json!({ "model": "org/pinned", "available_models": ["org/fast", "org/pinned"] }), ); assert_eq!(managed["model"], "governed/org/pinned"); diff --git a/crates/gh-config/src/contract_tests.rs b/crates/gh-config/src/contract_tests.rs index 5345cff..5ead0d9 100644 --- a/crates/gh-config/src/contract_tests.rs +++ b/crates/gh-config/src/contract_tests.rs @@ -363,6 +363,7 @@ fn every_production_interval_has_a_pure_golden_plan() { } let policy: HarnessPolicy = serde_json::from_value(serde_json::json!({ "managed_config": managed_config, + "gateway_models": if gateway_enabled { vec![managed_model.unwrap_or("fixture-model")] } else { Vec::<&str>::new() }, "mcp":[{"name":"fixture", "command":"fixture-mcp", "args":["--stdio"]}] })) .unwrap(); diff --git a/crates/gh-config/tests/golden/kimi-v0_0_0-gateway-nomodel.json b/crates/gh-config/tests/golden/kimi-v0_0_0-gateway-nomodel.json index 6496f8b..afdc550 100644 --- a/crates/gh-config/tests/golden/kimi-v0_0_0-gateway-nomodel.json +++ b/crates/gh-config/tests/golden/kimi-v0_0_0-gateway-nomodel.json @@ -29,7 +29,7 @@ "path": "$HOME/.config/blue/runtime/kimi/agents/reviewer.md" }, { - "body": "default_permission_mode = \"manual\"\nextra_agent_dirs = [\"$HOME/.config/blue/runtime/kimi/agents\"]\nextra_skill_dirs = [\"$HOME/.config/blue/runtime/kimi/skills\"]\n\n[[hooks]]\ncommand = \"'$BLUE' session-upload kimi --profile 'kimi-v0_0_0'\"\nevent = \"SessionEnd\"\ntimeout = 30\n\n[[hooks]]\ncommand = \"'$BLUE' session-upload kimi --profile 'kimi-v0_0_0'\"\nevent = \"Stop\"\ntimeout = 30\n\n[[hooks]]\ncommand = \"'$BLUE' session-start kimi --profile 'kimi-v0_0_0'\"\nevent = \"SessionStart\"\ntimeout = 30\n\n[[hooks]]\ncommand = \"fixture-hook\"\nevent = \"Stop\"\n\n[providers.governed]\napi_key = \"fixture-token\"\nbase_url = \"https://gateway.example\"\ntype = \"openai\"\n", + "body": "default_model = \"fixture-model\"\ndefault_permission_mode = \"manual\"\nextra_agent_dirs = [\"$HOME/.config/blue/runtime/kimi/agents\"]\nextra_skill_dirs = [\"$HOME/.config/blue/runtime/kimi/skills\"]\n\n[[hooks]]\ncommand = \"'$BLUE' session-upload kimi --profile 'kimi-v0_0_0'\"\nevent = \"SessionEnd\"\ntimeout = 30\n\n[[hooks]]\ncommand = \"'$BLUE' session-upload kimi --profile 'kimi-v0_0_0'\"\nevent = \"Stop\"\ntimeout = 30\n\n[[hooks]]\ncommand = \"'$BLUE' session-start kimi --profile 'kimi-v0_0_0'\"\nevent = \"SessionStart\"\ntimeout = 30\n\n[[hooks]]\ncommand = \"fixture-hook\"\nevent = \"Stop\"\n\n[models.fixture-model]\nmax_context_size = 262144\nmodel = \"fixture-model\"\nprovider = \"governed\"\n\n[providers.governed]\napi_key = \"fixture-token\"\nbase_url = \"https://gateway.example\"\ntype = \"openai\"\n", "mode": 384, "path": "$HOME/.config/blue/runtime/kimi/config.toml" }, diff --git a/crates/gh-config/tests/golden/opencode-v0_0_0-gateway-nomodel.json b/crates/gh-config/tests/golden/opencode-v0_0_0-gateway-nomodel.json index d957e22..2a8853f 100644 --- a/crates/gh-config/tests/golden/opencode-v0_0_0-gateway-nomodel.json +++ b/crates/gh-config/tests/golden/opencode-v0_0_0-gateway-nomodel.json @@ -3,7 +3,7 @@ "env": { "BLUE_OPENCODE_GATEWAY_TOKEN": "fixture-token", "HARNESS_PACKAGE_COMPONENTS_SETTINGS": "{\"color\":\"blue\"}", - "OPENCODE_CONFIG_CONTENT": "{\n \"$schema\": \"https://opencode.ai/config.json\",\n \"autoupdate\": false,\n \"enabled_providers\": [\n \"governed\"\n ],\n \"mcp\": {\n \"fixture\": {\n \"command\": [\n \"fixture-mcp\",\n \"--stdio\"\n ],\n \"enabled\": true,\n \"type\": \"local\"\n }\n },\n \"model\": \"governed/openai/gpt-5.6-sol\",\n \"permission\": {\n \"bash\": \"ask\",\n \"edit\": \"ask\",\n \"external_directory\": \"ask\",\n \"fetch\": \"allow\",\n \"glob\": \"allow\",\n \"grep\": \"allow\",\n \"list\": \"allow\",\n \"mcp\": \"allow\",\n \"question\": \"deny\",\n \"read\": \"allow\",\n \"skill\": \"allow\",\n \"task\": \"allow\",\n \"todoread\": \"allow\",\n \"todowrite\": \"allow\",\n \"write\": \"ask\"\n },\n \"provider\": {\n \"governed\": {\n \"models\": {\n \"openai/gpt-5.6-sol\": {\n \"name\": \"openai/gpt-5.6-sol\"\n }\n },\n \"name\": \"Governed (Blue)\",\n \"npm\": \"@ai-sdk/openai-compatible\",\n \"options\": {\n \"apiKey\": \"{env:BLUE_OPENCODE_GATEWAY_TOKEN}\",\n \"baseURL\": \"https://gateway.example\"\n }\n }\n }\n}\n", + "OPENCODE_CONFIG_CONTENT": "{\n \"$schema\": \"https://opencode.ai/config.json\",\n \"autoupdate\": false,\n \"enabled_providers\": [\n \"governed\"\n ],\n \"mcp\": {\n \"fixture\": {\n \"command\": [\n \"fixture-mcp\",\n \"--stdio\"\n ],\n \"enabled\": true,\n \"type\": \"local\"\n }\n },\n \"model\": \"governed/fixture-model\",\n \"permission\": {\n \"bash\": \"ask\",\n \"edit\": \"ask\",\n \"external_directory\": \"ask\",\n \"fetch\": \"allow\",\n \"glob\": \"allow\",\n \"grep\": \"allow\",\n \"list\": \"allow\",\n \"mcp\": \"allow\",\n \"question\": \"deny\",\n \"read\": \"allow\",\n \"skill\": \"allow\",\n \"task\": \"allow\",\n \"todoread\": \"allow\",\n \"todowrite\": \"allow\",\n \"write\": \"ask\"\n },\n \"provider\": {\n \"governed\": {\n \"models\": {\n \"fixture-model\": {\n \"name\": \"fixture-model\"\n }\n },\n \"name\": \"Governed (Blue)\",\n \"npm\": \"@ai-sdk/openai-compatible\",\n \"options\": {\n \"apiKey\": \"{env:BLUE_OPENCODE_GATEWAY_TOKEN}\",\n \"baseURL\": \"https://gateway.example\"\n }\n }\n }\n}\n", "OPENCODE_CONFIG_DIR": "$HOME/.config/blue/runtime/opencode", "OPENCODE_DISABLE_AUTOUPDATE": "1", "PATH": "$HOME/package/bin:$PATH" @@ -30,7 +30,7 @@ "$HOME/.config/blue/runtime/opencode/skills" ], "warnings": [ - "`harnesses.opencode.managed_config.model` is unset; defaulting to `openai/gpt-5.6-sol`. Set it in your governance config to pin the model your gateway serves." + "`harnesses.opencode.managed_config.model` is unset; defaulting to `fixture-model`. Set it in your governance config to pin the model your gateway serves." ], "writes": [ { @@ -39,7 +39,7 @@ "path": "$HOME/.config/blue/runtime/opencode/agents/reviewer.md" }, { - "body": "{\n \"$schema\": \"https://opencode.ai/config.json\",\n \"enabled_providers\": [\n \"governed\"\n ],\n \"mcp\": {\n \"fixture\": {\n \"command\": [\n \"fixture-mcp\",\n \"--stdio\"\n ],\n \"enabled\": true,\n \"type\": \"local\"\n }\n },\n \"model\": \"governed/openai/gpt-5.6-sol\",\n \"permission\": {\n \"bash\": \"ask\",\n \"edit\": \"ask\",\n \"external_directory\": \"ask\",\n \"fetch\": \"allow\",\n \"glob\": \"allow\",\n \"grep\": \"allow\",\n \"list\": \"allow\",\n \"mcp\": \"allow\",\n \"question\": \"deny\",\n \"read\": \"allow\",\n \"skill\": \"allow\",\n \"task\": \"allow\",\n \"todoread\": \"allow\",\n \"todowrite\": \"allow\",\n \"write\": \"ask\"\n },\n \"provider\": {\n \"governed\": {\n \"models\": {\n \"openai/gpt-5.6-sol\": {\n \"name\": \"openai/gpt-5.6-sol\"\n }\n },\n \"name\": \"Governed (Blue)\",\n \"npm\": \"@ai-sdk/openai-compatible\",\n \"options\": {\n \"apiKey\": \"{env:BLUE_OPENCODE_GATEWAY_TOKEN}\",\n \"baseURL\": \"https://gateway.example\"\n }\n }\n }\n}\n", + "body": "{\n \"$schema\": \"https://opencode.ai/config.json\",\n \"enabled_providers\": [\n \"governed\"\n ],\n \"mcp\": {\n \"fixture\": {\n \"command\": [\n \"fixture-mcp\",\n \"--stdio\"\n ],\n \"enabled\": true,\n \"type\": \"local\"\n }\n },\n \"model\": \"governed/fixture-model\",\n \"permission\": {\n \"bash\": \"ask\",\n \"edit\": \"ask\",\n \"external_directory\": \"ask\",\n \"fetch\": \"allow\",\n \"glob\": \"allow\",\n \"grep\": \"allow\",\n \"list\": \"allow\",\n \"mcp\": \"allow\",\n \"question\": \"deny\",\n \"read\": \"allow\",\n \"skill\": \"allow\",\n \"task\": \"allow\",\n \"todoread\": \"allow\",\n \"todowrite\": \"allow\",\n \"write\": \"ask\"\n },\n \"provider\": {\n \"governed\": {\n \"models\": {\n \"fixture-model\": {\n \"name\": \"fixture-model\"\n }\n },\n \"name\": \"Governed (Blue)\",\n \"npm\": \"@ai-sdk/openai-compatible\",\n \"options\": {\n \"apiKey\": \"{env:BLUE_OPENCODE_GATEWAY_TOKEN}\",\n \"baseURL\": \"https://gateway.example\"\n }\n }\n }\n}\n", "mode": 384, "path": "$HOME/.config/blue/runtime/opencode/opencode.json" }, diff --git a/crates/gh-config/tests/golden/opencode-v0_0_0-gateway.json b/crates/gh-config/tests/golden/opencode-v0_0_0-gateway.json index 76b3651..0cde174 100644 --- a/crates/gh-config/tests/golden/opencode-v0_0_0-gateway.json +++ b/crates/gh-config/tests/golden/opencode-v0_0_0-gateway.json @@ -3,7 +3,7 @@ "env": { "BLUE_OPENCODE_GATEWAY_TOKEN": "fixture-token", "HARNESS_PACKAGE_COMPONENTS_SETTINGS": "{\"color\":\"blue\"}", - "OPENCODE_CONFIG_CONTENT": "{\n \"$schema\": \"https://opencode.ai/config.json\",\n \"autoupdate\": false,\n \"enabled_providers\": [\n \"governed\"\n ],\n \"mcp\": {\n \"fixture\": {\n \"command\": [\n \"fixture-mcp\",\n \"--stdio\"\n ],\n \"enabled\": true,\n \"type\": \"local\"\n }\n },\n \"model\": \"governed/fixture-model\",\n \"permission\": {\n \"bash\": \"ask\",\n \"edit\": \"ask\",\n \"external_directory\": \"ask\",\n \"fetch\": \"allow\",\n \"glob\": \"allow\",\n \"grep\": \"allow\",\n \"list\": \"allow\",\n \"mcp\": \"allow\",\n \"question\": \"deny\",\n \"read\": \"allow\",\n \"skill\": \"allow\",\n \"task\": \"allow\",\n \"todoread\": \"allow\",\n \"todowrite\": \"allow\",\n \"write\": \"ask\"\n },\n \"provider\": {\n \"governed\": {\n \"models\": {\n \"fixture-model\": {\n \"name\": \"fixture-model\"\n },\n \"openai/gpt-5.6-sol\": {\n \"name\": \"openai/gpt-5.6-sol\"\n }\n },\n \"name\": \"Governed (Blue)\",\n \"npm\": \"@ai-sdk/openai-compatible\",\n \"options\": {\n \"apiKey\": \"{env:BLUE_OPENCODE_GATEWAY_TOKEN}\",\n \"baseURL\": \"https://gateway.example\"\n }\n }\n }\n}\n", + "OPENCODE_CONFIG_CONTENT": "{\n \"$schema\": \"https://opencode.ai/config.json\",\n \"autoupdate\": false,\n \"enabled_providers\": [\n \"governed\"\n ],\n \"mcp\": {\n \"fixture\": {\n \"command\": [\n \"fixture-mcp\",\n \"--stdio\"\n ],\n \"enabled\": true,\n \"type\": \"local\"\n }\n },\n \"model\": \"governed/fixture-model\",\n \"permission\": {\n \"bash\": \"ask\",\n \"edit\": \"ask\",\n \"external_directory\": \"ask\",\n \"fetch\": \"allow\",\n \"glob\": \"allow\",\n \"grep\": \"allow\",\n \"list\": \"allow\",\n \"mcp\": \"allow\",\n \"question\": \"deny\",\n \"read\": \"allow\",\n \"skill\": \"allow\",\n \"task\": \"allow\",\n \"todoread\": \"allow\",\n \"todowrite\": \"allow\",\n \"write\": \"ask\"\n },\n \"provider\": {\n \"governed\": {\n \"models\": {\n \"fixture-model\": {\n \"name\": \"fixture-model\"\n }\n },\n \"name\": \"Governed (Blue)\",\n \"npm\": \"@ai-sdk/openai-compatible\",\n \"options\": {\n \"apiKey\": \"{env:BLUE_OPENCODE_GATEWAY_TOKEN}\",\n \"baseURL\": \"https://gateway.example\"\n }\n }\n }\n}\n", "OPENCODE_CONFIG_DIR": "$HOME/.config/blue/runtime/opencode", "OPENCODE_DISABLE_AUTOUPDATE": "1", "PATH": "$HOME/package/bin:$PATH" @@ -37,7 +37,7 @@ "path": "$HOME/.config/blue/runtime/opencode/agents/reviewer.md" }, { - "body": "{\n \"$schema\": \"https://opencode.ai/config.json\",\n \"enabled_providers\": [\n \"governed\"\n ],\n \"mcp\": {\n \"fixture\": {\n \"command\": [\n \"fixture-mcp\",\n \"--stdio\"\n ],\n \"enabled\": true,\n \"type\": \"local\"\n }\n },\n \"model\": \"governed/fixture-model\",\n \"permission\": {\n \"bash\": \"ask\",\n \"edit\": \"ask\",\n \"external_directory\": \"ask\",\n \"fetch\": \"allow\",\n \"glob\": \"allow\",\n \"grep\": \"allow\",\n \"list\": \"allow\",\n \"mcp\": \"allow\",\n \"question\": \"deny\",\n \"read\": \"allow\",\n \"skill\": \"allow\",\n \"task\": \"allow\",\n \"todoread\": \"allow\",\n \"todowrite\": \"allow\",\n \"write\": \"ask\"\n },\n \"provider\": {\n \"governed\": {\n \"models\": {\n \"fixture-model\": {\n \"name\": \"fixture-model\"\n },\n \"openai/gpt-5.6-sol\": {\n \"name\": \"openai/gpt-5.6-sol\"\n }\n },\n \"name\": \"Governed (Blue)\",\n \"npm\": \"@ai-sdk/openai-compatible\",\n \"options\": {\n \"apiKey\": \"{env:BLUE_OPENCODE_GATEWAY_TOKEN}\",\n \"baseURL\": \"https://gateway.example\"\n }\n }\n }\n}\n", + "body": "{\n \"$schema\": \"https://opencode.ai/config.json\",\n \"enabled_providers\": [\n \"governed\"\n ],\n \"mcp\": {\n \"fixture\": {\n \"command\": [\n \"fixture-mcp\",\n \"--stdio\"\n ],\n \"enabled\": true,\n \"type\": \"local\"\n }\n },\n \"model\": \"governed/fixture-model\",\n \"permission\": {\n \"bash\": \"ask\",\n \"edit\": \"ask\",\n \"external_directory\": \"ask\",\n \"fetch\": \"allow\",\n \"glob\": \"allow\",\n \"grep\": \"allow\",\n \"list\": \"allow\",\n \"mcp\": \"allow\",\n \"question\": \"deny\",\n \"read\": \"allow\",\n \"skill\": \"allow\",\n \"task\": \"allow\",\n \"todoread\": \"allow\",\n \"todowrite\": \"allow\",\n \"write\": \"ask\"\n },\n \"provider\": {\n \"governed\": {\n \"models\": {\n \"fixture-model\": {\n \"name\": \"fixture-model\"\n }\n },\n \"name\": \"Governed (Blue)\",\n \"npm\": \"@ai-sdk/openai-compatible\",\n \"options\": {\n \"apiKey\": \"{env:BLUE_OPENCODE_GATEWAY_TOKEN}\",\n \"baseURL\": \"https://gateway.example\"\n }\n }\n }\n}\n", "mode": 384, "path": "$HOME/.config/blue/runtime/opencode/opencode.json" }, diff --git a/crates/gh-gateway-provisioner/src/lib.rs b/crates/gh-gateway-provisioner/src/lib.rs index 7e6a9ad..2bcae23 100644 --- a/crates/gh-gateway-provisioner/src/lib.rs +++ b/crates/gh-gateway-provisioner/src/lib.rs @@ -10,6 +10,7 @@ pub mod litellm; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::collections::BTreeMap; use thiserror::Error; use zeroize::{Zeroize, ZeroizeOnDrop}; @@ -99,6 +100,23 @@ pub struct RevokeResponse { pub revoked: bool, } +/// A provider-neutral model returned by gateway discovery. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct DiscoveredModel { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_name: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub metadata: BTreeMap, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ModelCatalog { + pub models: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_revision: Option, +} + #[derive(Debug, Error)] pub enum ProvisionerError { #[error("invalid provisioning configuration: {0}")] @@ -113,6 +131,12 @@ pub enum ProvisionerError { Unavailable(String), #[error("gateway rejected provisioning: {0}")] Rejected(String), + #[error("gateway model discovery is unsupported")] + DiscoveryUnsupported, + #[error("gateway rejected model discovery authentication: {0}")] + DiscoveryAuth(String), + #[error("gateway returned an invalid model catalog: {0}")] + DiscoveryResponse(String), } #[async_trait] @@ -126,6 +150,13 @@ pub trait GatewayProvisioner: Send + Sync + 'static { None } + /// Discover the gateway's current model catalog. Provisioners predating + /// this capability remain source-compatible and explicitly report that + /// discovery is unsupported. + async fn list_models(&self) -> Result { + Err(ProvisionerError::DiscoveryUnsupported) + } + async fn ensure( &self, request: EnsureRequest, diff --git a/crates/gh-gateway-provisioner/src/litellm/mod.rs b/crates/gh-gateway-provisioner/src/litellm/mod.rs index 3bb199c..f94213b 100644 --- a/crates/gh-gateway-provisioner/src/litellm/mod.rs +++ b/crates/gh-gateway-provisioner/src/litellm/mod.rs @@ -1,6 +1,6 @@ use crate::{ - EnsureRequest, GatewayProvisioner, ProvisionedCredential, ProvisionerError, RevokeRequest, - RevokeResponse, SecretString, + DiscoveredModel, EnsureRequest, GatewayProvisioner, ModelCatalog, ProvisionedCredential, + ProvisionerError, RevokeRequest, RevokeResponse, SecretString, }; use async_trait::async_trait; use serde_json::{json, Value}; @@ -294,6 +294,66 @@ impl GatewayProvisioner for LiteLlmProvisioner { "builtin-litellm" } + async fn list_models(&self) -> Result { + let url = format!("{}/v1/models", self.base_url.trim_end_matches('/')); + let response = self + .http + .get(url) + .bearer_auth(&self.admin_key) + .header("accept", "application/json") + .send() + .await + .map_err(|_| ProvisionerError::Unavailable("model discovery request failed".into()))?; + let status = response.status(); + let bytes = response.bytes().await.map_err(|_| { + ProvisionerError::DiscoveryResponse("gateway returned an unreadable response".into()) + })?; + if matches!( + status, + reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN + ) { + return Err(ProvisionerError::DiscoveryAuth( + self.rejection_message(status, &bytes), + )); + } + if !status.is_success() { + return Err(ProvisionerError::Unavailable( + self.rejection_message(status, &bytes), + )); + } + let value: Value = serde_json::from_slice(&bytes).map_err(|_| { + ProvisionerError::DiscoveryResponse("gateway returned invalid JSON".into()) + })?; + let entries = value.get("data").and_then(Value::as_array).ok_or_else(|| { + ProvisionerError::DiscoveryResponse("response has no data array".into()) + })?; + let mut models = entries + .iter() + .filter_map(|entry| entry.get("id").and_then(Value::as_str)) + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(|id| DiscoveredModel { + id: id.to_owned(), + display_name: None, + // LiteLLM's OpenAI-compatible endpoint guarantees `id`; keep + // undocumented and potentially volatile fields out of drift. + metadata: Default::default(), + }) + .collect::>(); + models.sort_by(|left, right| left.id.cmp(&right.id)); + models.dedup_by(|left, right| left.id == right.id); + let revision = hex::encode(Sha256::digest( + models + .iter() + .flat_map(|model| model.id.as_bytes().iter().copied().chain([0])) + .collect::>(), + )); + Ok(ModelCatalog { + models, + source_revision: Some(revision), + }) + } + async fn ensure( &self, request: EnsureRequest, diff --git a/crates/gh-service/src/schema.rs b/crates/gh-service/src/schema.rs index 0f9e372..5c73b53 100644 --- a/crates/gh-service/src/schema.rs +++ b/crates/gh-service/src/schema.rs @@ -60,7 +60,7 @@ pub struct GovernanceConfig { impl GovernanceConfig { pub const DEFAULT_TTL_SECONDS: u64 = 300; - pub const CONTRACT_VERSION: u32 = 3; + pub const CONTRACT_VERSION: u32 = 4; pub const CAPABILITIES: &'static [&'static str] = &[ "adapter_intervals", "compiled_harness_registry", @@ -68,6 +68,7 @@ impl GovernanceConfig { "versioned_state", "unverified_harness_versions", "gateway_inference_jwt", + "gateway_model_catalog", ]; pub fn ttl_seconds(&self) -> u64 { @@ -121,6 +122,10 @@ pub struct HarnessPolicy { /// scoped by an administrator-authored range. #[serde(default, skip_serializing_if = "is_false")] pub allow_unverified_versions: bool, + /// Exact provider model IDs assigned to this harness in gateway mode. + /// Direct-mode adapters intentionally ignore this catalog. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub gateway_models: Vec, /// Model / approval / permission flags written into the harness's config. #[serde(default)] pub managed_config: ManagedConfig, diff --git a/deploy/blue.yaml b/deploy/blue.yaml index e4c3136..89f68bd 100644 --- a/deploy/blue.yaml +++ b/deploy/blue.yaml @@ -55,7 +55,7 @@ control_api: # is projected into this policy; runtime settings and secrets never leave the server. governance: revision: "2026-09-06.1" - contract_version: 3 + contract_version: 4 required_capabilities: - adapter_intervals - compiled_harness_registry @@ -65,6 +65,7 @@ governance: - versioned_state # Required when the gateway overlay is active. - gateway_inference_jwt + - gateway_model_catalog # Required when harness version ranges or versioned package adapters are used. minimum_client_version: "0.1.0" ttl_seconds: 300 @@ -79,6 +80,7 @@ governance: # config format. Also add unverified_harness_versions to # required_capabilities so older clients fail closed. # allow_unverified_versions: true + gateway_models: [gpt-5.6-sol] managed_config: model: gpt-5.6-sol reasoning_effort: low @@ -89,13 +91,16 @@ governance: # generation onward. Keep the local stack from selecting an older native # layout for packages added through the dashboard. version_requirement: ">=2.0.12, <2.1.253-0" + gateway_models: [claude-opus-4-8] managed_config: model: claude-opus-4-8 kimi: + gateway_models: [moonshot-ai/kimi-k2.6] managed_config: model: moonshot-ai/kimi-k2.6 auto_approve: true opencode: + gateway_models: [anthropic/claude-opus-4-8] managed_config: model: anthropic/claude-opus-4-8 diff --git a/deploy/contract/governance.openapi.yaml b/deploy/contract/governance.openapi.yaml index f973c2b..45a453c 100644 --- a/deploy/contract/governance.openapi.yaml +++ b/deploy/contract/governance.openapi.yaml @@ -1765,6 +1765,11 @@ components: properties: version_requirement: { type: string, description: Semver range required for the locally installed harness } allow_unverified_versions: { type: boolean, default: false, description: Permit matching releases beyond Blue's certified ceiling; requires version_requirement } + gateway_models: + type: array + uniqueItems: true + description: Exact gateway model IDs assigned to this harness; ignored in direct mode. + items: { type: string, minLength: 1 } managed_config: { $ref: "#/components/schemas/ManagedConfig" } mcp: type: array diff --git a/services/control-api/migrations/0035_gateway_model_catalog.sql b/services/control-api/migrations/0035_gateway_model_catalog.sql new file mode 100644 index 0000000..f4d9d93 --- /dev/null +++ b/services/control-api/migrations/0035_gateway_model_catalog.sql @@ -0,0 +1,24 @@ +CREATE TABLE public.gateway_model_catalog ( + organization_id uuid NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE, + gateway_type text NOT NULL, + model_id text NOT NULL CHECK (btrim(model_id) <> ''), + display_name text, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + fingerprint text NOT NULL, + first_seen_at timestamptz NOT NULL DEFAULT now(), + last_seen_at timestamptz NOT NULL DEFAULT now(), + unavailable_since timestamptz, + PRIMARY KEY (organization_id, gateway_type, model_id) +); + +CREATE TABLE public.gateway_model_catalog_sync_state ( + organization_id uuid NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE, + gateway_type text NOT NULL, + last_refresh_at timestamptz, + last_successful_refresh_at timestamptz, + last_successful_source_revision text, + latest_error text, + latest_error_at timestamptz, + discovery_supported boolean, + PRIMARY KEY (organization_id, gateway_type) +); diff --git a/services/control-api/src/executable_provisioner.rs b/services/control-api/src/executable_provisioner.rs index eac4af2..9dbcae8 100644 --- a/services/control-api/src/executable_provisioner.rs +++ b/services/control-api/src/executable_provisioner.rs @@ -5,8 +5,8 @@ use std::time::Duration; use async_trait::async_trait; use gh_gateway_provisioner::{ - EnsureRequest, GatewayProvisioner, ProvisionedCredential, ProvisionerError, RevokeRequest, - RevokeResponse, + EnsureRequest, GatewayProvisioner, ModelCatalog, ProvisionedCredential, ProvisionerError, + RevokeRequest, RevokeResponse, }; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -245,6 +245,9 @@ fn map_error(error: WireError) -> ProvisionerError { "credential_invalid" => ProvisionerError::CredentialInvalid(error.message), "unavailable" => ProvisionerError::Unavailable(error.message), "rejected" => ProvisionerError::Rejected(error.message), + "unsupported" | "discovery_unsupported" => ProvisionerError::DiscoveryUnsupported, + "auth" | "discovery_auth" => ProvisionerError::DiscoveryAuth(error.message), + "response" | "discovery_response" => ProvisionerError::DiscoveryResponse(error.message), _ => unavailable("provisioner returned an unsupported error code"), } } @@ -275,6 +278,9 @@ impl GatewayProvisioner for ExecutableGatewayProvisioner { async fn revoke(&self, request: RevokeRequest) -> Result { self.invoke("revoke", request).await } + async fn list_models(&self) -> Result { + self.invoke("list_models", serde_json::json!({})).await + } } #[cfg(test)] @@ -454,6 +460,9 @@ mod tests { assert_eq!(message, "public"); 502 } + ProvisionerError::DiscoveryUnsupported + | ProvisionerError::DiscoveryAuth(_) + | ProvisionerError::DiscoveryResponse(_) => 502, }; assert_eq!(actual, expected_status); } diff --git a/services/control-api/src/lib.rs b/services/control-api/src/lib.rs index 05dc05f..5579e79 100644 --- a/services/control-api/src/lib.rs +++ b/services/control-api/src/lib.rs @@ -2711,6 +2711,12 @@ async fn reconcile_deployment_governance( .map_err(|error| { ApiError::internal(format!("decoding merged governance config: {error}")) })?; + // The dashboard may have saved its capability list before the deployment + // enabled a newer gateway feature. Reassert the server-owned gateway mode + // after the three-way merge so its required capability floor cannot be + // lost to an older saved revision. + apply_deployment_gateway_policy(&mut merged, config.gateway_kind.as_deref()); + let merged_value = normalized_governance_value(&merged)?; validate_complete_governance(&merged).map_err(|error| { ApiError::internal(format!("merged governance config is invalid: {error}")) })?; @@ -3793,6 +3799,9 @@ fn provisioner_api_error(error: ProvisionerError) -> ApiError { ProvisionerError::Conflict(_) => StatusCode::CONFLICT, ProvisionerError::CredentialInvalid(_) => StatusCode::CONFLICT, ProvisionerError::Unavailable(_) | ProvisionerError::Rejected(_) => StatusCode::BAD_GATEWAY, + ProvisionerError::DiscoveryUnsupported => StatusCode::NOT_IMPLEMENTED, + ProvisionerError::DiscoveryAuth(_) => StatusCode::UNAUTHORIZED, + ProvisionerError::DiscoveryResponse(_) => StatusCode::BAD_GATEWAY, }; ApiError::new(status, error.to_string()) } @@ -6589,6 +6598,19 @@ fn validate_complete_governance(config: &gh_service::GovernanceConfig) -> Result { return Err("gateway mode requires capability `gateway_inference_jwt`".into()); } + let uses_gateway_models = config + .harnesses + .values() + .any(|policy| !policy.gateway_models.is_empty()); + if config.gateway.is_some() + && uses_gateway_models + && !config + .required_capabilities + .iter() + .any(|capability| capability == "gateway_model_catalog") + { + return Err("gateway model mappings require capability `gateway_model_catalog`".into()); + } if let Some(gateway) = config.gateway.as_ref() { if gateway.proxy_url.is_some() || gateway.token.is_some() { return Err("gateway proxy_url and token are runtime-only fields".into()); @@ -6617,6 +6639,32 @@ fn validate_complete_governance(config: &gh_service::GovernanceConfig) -> Result ) })?; } + let mut unique_models = std::collections::BTreeSet::new(); + for model in &policy.gateway_models { + if model.trim().is_empty() || model != model.trim() { + return Err(format!( + "harness `{harness}` has an invalid empty or untrimmed gateway model ID" + )); + } + if !unique_models.insert(model) { + return Err(format!( + "harness `{harness}` has duplicate gateway model `{model}`" + )); + } + } + if config.gateway.is_some() + && policy + .managed_config + .model + .as_ref() + .is_some_and(|selected| { + !policy.gateway_models.iter().any(|model| model == selected) + }) + { + return Err(format!( + "harness `{harness}` selected gateway model is not assigned to that harness" + )); + } } let uses_version_aware_features = config .harnesses @@ -8120,6 +8168,19 @@ fn stamp_version_aware_client_floor(config: &mut gh_service::GovernanceConfig) { .required_capabilities .push("gateway_inference_jwt".to_owned()); } + if config + .harnesses + .values() + .any(|policy| !policy.gateway_models.is_empty()) + && !config + .required_capabilities + .iter() + .any(|capability| capability == "gateway_model_catalog") + { + config + .required_capabilities + .push("gateway_model_catalog".to_owned()); + } } }