Skip to content
Draft
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
26 changes: 26 additions & 0 deletions apps/dashboard/lib/gateway-model-compatibility.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
51 changes: 51 additions & 0 deletions apps/dashboard/lib/gateway-model-compatibility.ts
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions apps/dashboard/lib/harness-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions apps/dashboard/lib/mapping-compatibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const claude: HarnessMetadata = {
description: "",
binary_names: ["claude"],
install_command_template: "",
gateway_model_exposure: "selected_only",
capabilities: [],
component_rules: openRules,
generations: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
22 changes: 18 additions & 4 deletions apps/docs/next/reference/governance-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`.

Expand All @@ -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

Expand Down
5 changes: 5 additions & 0 deletions apps/docs/openapi/next.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions crates/gh-config/src/adapters/claude/writer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ pub fn write(
session_start_hooks: Option<Value>,
_enforced: bool,
) -> Result<HarnessWrite, GhError> {
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");
Expand Down
10 changes: 10 additions & 0 deletions crates/gh-config/src/adapters/codex/writer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@ pub fn write(
session_upload_hook: Option<Toml>,
session_start_hook: Option<Toml>,
) -> Result<HarnessWrite, GhError> {
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)?;
Expand Down
50 changes: 40 additions & 10 deletions crates/gh-config/src/adapters/kimi/writer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>();
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-
Expand All @@ -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()));
Expand All @@ -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();
Expand Down Expand Up @@ -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::<Toml>()
Expand Down
12 changes: 12 additions & 0 deletions crates/gh-config/src/adapters/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -494,6 +502,10 @@ pub fn registry_metadata() -> Vec<PublicHarnessMetadata> {
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
Expand Down
Loading
Loading