From 40f9c806c41271290b3872bc02f403279a8180fb Mon Sep 17 00:00:00 2001 From: "John Bufe (he/him) (from Dev Box)" Date: Tue, 1 Sep 2026 13:06:58 -0400 Subject: [PATCH 1/2] Add Rust model allowlist support Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- rust/src/generated/api_types.rs | 68 +++++++++++++++++++++++ rust/src/generated/rpc.rs | 36 +++++++++++++ rust/src/types.rs | 91 +++++++++++++++++++++++++++++++ rust/src/wire.rs | 4 ++ rust/tests/session_test.rs | 95 ++++++++++++++++++++++++++++++++- 5 files changed, 293 insertions(+), 1 deletion(-) diff --git a/rust/src/generated/api_types.rs b/rust/src/generated/api_types.rs index 68d8b7d054..d62bbcdedb 100644 --- a/rust/src/generated/api_types.rs +++ b/rust/src/generated/api_types.rs @@ -292,6 +292,8 @@ pub mod rpc_methods { pub const SESSION_MODEL_SETREASONINGEFFORT: &str = "session.model.setReasoningEffort"; /// `session.model.list` pub const SESSION_MODEL_LIST: &str = "session.model.list"; + /// `session.model.setAllowedModels` + pub const SESSION_MODEL_SETALLOWEDMODELS: &str = "session.model.setAllowedModels"; /// `session.mode.get` pub const SESSION_MODE_GET: &str = "session.mode.get"; /// `session.mode.set` @@ -21593,6 +21595,47 @@ pub struct WorkspacesWriteAutopilotObjectiveResult { pub operation: String, } +/// Host-supplied exact CAPI model IDs to allow for this running session. The runtime intersects the list with repository `.github/allowed_models.txt` policy. Omit or pass null to clear the host restriction; an explicit empty or disjoint list is rejected. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelSetAllowedModelsRequest { + /// Exact model IDs to permit, or null to clear the host restriction. + #[serde(skip_serializing_if = "Option::is_none")] + pub allowed_models: Option>, +} + +/// The applied host allowlist and effective session model policy after intersection. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelSetAllowedModelsResult { + /// Normalized host allowlist. Omitted when the host restriction was cleared. + #[serde(skip_serializing_if = "Option::is_none")] + pub allowed_models: Option>, + /// Effective exact IDs or repository policy patterns after applying the host restriction. Omitted by relay clients whose AHP host applies the policy asynchronously. + #[serde(skip_serializing_if = "Option::is_none")] + pub effective_allowed_models: Option>, + /// Effective deterministic fallback model, when the policy defines one. + #[serde(skip_serializing_if = "Option::is_none")] + pub fallback_model: Option, + /// Selected session model after reconciling a now-disallowed concrete selection. + #[serde(skip_serializing_if = "Option::is_none")] + pub model_id: Option, +} + /// List of Copilot models available to the resolved user, including capabilities and billing metadata. /// ///
@@ -23102,6 +23145,31 @@ pub struct SessionModelListResult { pub quota_snapshots: Option>, } +/// The applied host allowlist and effective session model policy after intersection. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionModelSetAllowedModelsResult { + /// Normalized host allowlist. Omitted when the host restriction was cleared. + #[serde(skip_serializing_if = "Option::is_none")] + pub allowed_models: Option>, + /// Effective exact IDs or repository policy patterns after applying the host restriction. Omitted by relay clients whose AHP host applies the policy asynchronously. + #[serde(skip_serializing_if = "Option::is_none")] + pub effective_allowed_models: Option>, + /// Effective deterministic fallback model, when the policy defines one. + #[serde(skip_serializing_if = "Option::is_none")] + pub fallback_model: Option, + /// Selected session model after reconciling a now-disallowed concrete selection. + #[serde(skip_serializing_if = "Option::is_none")] + pub model_id: Option, +} + /// Identifies the target session. /// ///
diff --git a/rust/src/generated/rpc.rs b/rust/src/generated/rpc.rs index d31cea5ce9..990ba4587c 100644 --- a/rust/src/generated/rpc.rs +++ b/rust/src/generated/rpc.rs @@ -7621,6 +7621,42 @@ impl<'a> SessionRpcModel<'a> { .await?; Ok(serde_json::from_value(_value)?) } + + /// Replaces or clears the host-supplied model allowlist for a running session. + /// + /// Wire method: `session.model.setAllowedModels`. + /// + /// # Parameters + /// + /// * `params` - Host-supplied exact CAPI model IDs to allow for this running session. The runtime intersects the list with repository `.github/allowed_models.txt` policy. Omit or pass null to clear the host restriction; an explicit empty or disjoint list is rejected. + /// + /// # Returns + /// + /// The applied host allowlist and effective session model policy after intersection. + /// + ///
+ /// + /// **Experimental.** This API is part of an experimental wire-protocol surface + /// and may change or be removed in future SDK or CLI releases. Pin both the + /// SDK and CLI versions if your code depends on it. + /// + ///
+ pub async fn set_allowed_models( + &self, + params: ModelSetAllowedModelsRequest, + ) -> Result { + let mut wire_params = serde_json::to_value(params)?; + wire_params["sessionId"] = serde_json::Value::String(self.session.id().to_string()); + let _value = self + .session + .client() + .call( + rpc_methods::SESSION_MODEL_SETALLOWEDMODELS, + Some(wire_params), + ) + .await?; + Ok(serde_json::from_value(_value)?) + } } /// `session.name.*` RPCs. diff --git a/rust/src/types.rs b/rust/src/types.rs index ee3ac3df26..46969e1cd0 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -1939,6 +1939,9 @@ pub struct SessionConfig { pub session_id: Option, /// Model to use (e.g. `"gpt-4"`, `"claude-sonnet-4"`). pub model: Option, + /// Exact model identifiers permitted for this session. When unset, the SDK + /// does not restrict model selection. + pub allowed_models: Option>, /// Application name sent as `User-Agent` context. pub client_name: Option, /// Reasoning effort level (e.g. `"low"`, `"medium"`, `"high"`). @@ -2273,6 +2276,7 @@ impl std::fmt::Debug for SessionConfig { f.debug_struct("SessionConfig") .field("session_id", &self.session_id) .field("model", &self.model) + .field("allowed_models", &self.allowed_models) .field("client_name", &self.client_name) .field("reasoning_effort", &self.reasoning_effort) .field("reasoning_summary", &self.reasoning_summary) @@ -2415,6 +2419,7 @@ impl Default for SessionConfig { Self { session_id: None, model: None, + allowed_models: None, client_name: None, reasoning_effort: None, reasoning_summary: None, @@ -2584,6 +2589,7 @@ impl SessionConfig { let wire = crate::wire::SessionCreateWire { session_id, model: self.model, + allowed_models: self.allowed_models, client_name: self.client_name, reasoning_effort: self.reasoning_effort, reasoning_summary: self.reasoning_summary, @@ -2807,6 +2813,16 @@ impl SessionConfig { self } + /// Set the exact model identifiers permitted for this session. + pub fn with_allowed_models(mut self, allowed_models: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.allowed_models = Some(allowed_models.into_iter().map(Into::into).collect()); + self + } + /// Set the application name sent as `User-Agent` context. pub fn with_client_name(mut self, name: impl Into) -> Self { self.client_name = Some(name.into()); @@ -3347,6 +3363,9 @@ pub struct ResumeSessionConfig { /// Model to use for this session (e.g. `"gpt-4"`, `"claude-sonnet-4"`). /// Can change the model when resuming. pub model: Option, + /// Exact model identifiers permitted for the resumed session. When unset, + /// the SDK does not restrict model selection. + pub allowed_models: Option>, /// Application name sent as User-Agent context. pub client_name: Option, /// Desired reasoning effort to apply after resuming the session. @@ -3609,6 +3628,7 @@ impl std::fmt::Debug for ResumeSessionConfig { f.debug_struct("ResumeSessionConfig") .field("session_id", &self.session_id) .field("model", &self.model) + .field("allowed_models", &self.allowed_models) .field("client_name", &self.client_name) .field("reasoning_effort", &self.reasoning_effort) .field("reasoning_summary", &self.reasoning_summary) @@ -3794,6 +3814,7 @@ impl ResumeSessionConfig { let wire = crate::wire::SessionResumeWire { session_id: self.session_id, model: self.model, + allowed_models: self.allowed_models, client_name: self.client_name, reasoning_effort: self.reasoning_effort, reasoning_summary: self.reasoning_summary, @@ -3902,6 +3923,7 @@ impl ResumeSessionConfig { Self { session_id, model: None, + allowed_models: None, client_name: None, reasoning_effort: None, reasoning_summary: None, @@ -4095,6 +4117,16 @@ impl ResumeSessionConfig { self } + /// Set the exact model identifiers permitted for the resumed session. + pub fn with_allowed_models(mut self, allowed_models: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.allowed_models = Some(allowed_models.into_iter().map(Into::into).collect()); + self + } + /// Set the application name sent as `User-Agent` context. pub fn with_client_name(mut self, name: impl Into) -> Self { self.client_name = Some(name.into()); @@ -6347,6 +6379,65 @@ mod tests { assert!(json.get("askUserVariant").is_none()); } + #[test] + fn session_config_allowed_models_builder_debug_and_wire() { + let default = SessionConfig::default(); + assert_eq!(default.allowed_models, None); + assert!(format!("{default:?}").contains("allowed_models: None")); + + let config = SessionConfig::default().with_allowed_models(["gpt-5", "claude-sonnet-5"]); + assert_eq!( + config.allowed_models.as_deref(), + Some(&["gpt-5".to_string(), "claude-sonnet-5".to_string()][..]) + ); + assert!( + format!("{config:?}") + .contains("allowed_models: Some([\"gpt-5\", \"claude-sonnet-5\"])") + ); + + let (wire, _) = config + .into_wire(Some(SessionId::from("allowed-models-create"))) + .expect("allowed models do not add SDK validation"); + let json = serde_json::to_value(&wire).unwrap(); + assert_eq!(json["allowedModels"], json!(["gpt-5", "claude-sonnet-5"])); + + let (default_wire, _) = SessionConfig::default() + .into_wire(Some(SessionId::from("unrestricted-create"))) + .expect("default config has no duplicate handlers"); + let default_json = serde_json::to_value(&default_wire).unwrap(); + assert!(default_json.get("allowedModels").is_none()); + } + + #[test] + fn resume_session_config_allowed_models_builder_debug_and_wire() { + let default = ResumeSessionConfig::new(SessionId::from("unrestricted-resume")); + assert_eq!(default.allowed_models, None); + assert!(format!("{default:?}").contains("allowed_models: None")); + + let config = ResumeSessionConfig::new(SessionId::from("allowed-models-resume")) + .with_allowed_models(vec!["gpt-5".to_string(), "claude-sonnet-5".to_string()]); + assert_eq!( + config.allowed_models.as_deref(), + Some(&["gpt-5".to_string(), "claude-sonnet-5".to_string()][..]) + ); + assert!( + format!("{config:?}") + .contains("allowed_models: Some([\"gpt-5\", \"claude-sonnet-5\"])") + ); + + let (wire, _) = config + .into_wire() + .expect("allowed models do not add SDK validation"); + let json = serde_json::to_value(&wire).unwrap(); + assert_eq!(json["allowedModels"], json!(["gpt-5", "claude-sonnet-5"])); + + let (default_wire, _) = ResumeSessionConfig::new(SessionId::from("unrestricted-resume")) + .into_wire() + .expect("default resume config has no duplicate handlers"); + let default_json = serde_json::to_value(&default_wire).unwrap(); + assert!(default_json.get("allowedModels").is_none()); + } + #[test] fn custom_agents_local_only_serializes_on_create_and_resume() { let (create_wire, _) = SessionConfig::default() diff --git a/rust/src/wire.rs b/rust/src/wire.rs index 75e17f4e9c..3a09bab77c 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -53,6 +53,8 @@ pub(crate) struct SessionCreateWire { #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub allowed_models: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub client_name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub reasoning_effort: Option, @@ -211,6 +213,8 @@ pub(crate) struct SessionResumeWire { #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub allowed_models: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub client_name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub reasoning_effort: Option, diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index 9f86777ffc..ca7e7e6c96 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -19,7 +19,7 @@ use github_copilot_sdk::handler::{ }; use github_copilot_sdk::rpc::{ CanvasProviderInvokeActionRequest, CanvasProviderOpenRequest, CanvasProviderOpenResult, - OpenCanvasInstance, + ModelSetAllowedModelsRequest, ModelSetAllowedModelsResult, OpenCanvasInstance, }; use github_copilot_sdk::session_events::{ ManagedSettingsResolvedSource, McpOauthRequiredData, ReasoningSummary, SessionLimitsConfig, @@ -2687,6 +2687,52 @@ async fn set_model_sends_switch_to_request() { timeout(TIMEOUT, handle).await.unwrap().unwrap(); } +#[test] +fn model_set_allowed_models_types_serialize_replacement_and_clear() { + let replacement = ModelSetAllowedModelsRequest { + allowed_models: Some(vec!["gpt-5".to_string(), "claude-sonnet-5".to_string()]), + }; + assert_eq!( + serde_json::to_value(replacement).unwrap(), + serde_json::json!({ + "allowedModels": ["gpt-5", "claude-sonnet-5"] + }) + ); + + assert_eq!( + serde_json::to_value(ModelSetAllowedModelsRequest { + allowed_models: None, + }) + .unwrap(), + serde_json::json!({}) + ); + + let result: ModelSetAllowedModelsResult = serde_json::from_value(serde_json::json!({ + "allowedModels": ["gpt-5"], + "effectiveAllowedModels": ["gpt-5", "claude-*"], + "fallbackModel": "gpt-5", + "modelId": "gpt-5" + })) + .unwrap(); + assert_eq!( + result.allowed_models.as_deref(), + Some(&["gpt-5".to_string()][..]) + ); + assert_eq!( + result.effective_allowed_models.as_deref(), + Some(&["gpt-5".to_string(), "claude-*".to_string()][..]) + ); + assert_eq!(result.fallback_model.as_deref(), Some("gpt-5")); + assert_eq!(result.model_id.as_deref(), Some("gpt-5")); + + let cleared: ModelSetAllowedModelsResult = + serde_json::from_value(serde_json::json!({})).unwrap(); + assert_eq!(cleared.allowed_models, None); + assert_eq!(cleared.effective_allowed_models, None); + assert_eq!(cleared.fallback_model, None); + assert_eq!(cleared.model_id, None); +} + #[tokio::test] async fn elicitation_returns_typed_result() { let (session, mut server) = @@ -4767,6 +4813,53 @@ async fn rpc_namespace_session_tasks_list_dispatches_correctly() { assert!(result.tasks.is_empty()); } +#[tokio::test] +async fn rpc_namespace_session_model_set_allowed_models_dispatches_correctly() { + let (session, mut server) = create_session_pair().await; + let session = Arc::new(session); + + let s = session.clone(); + let handle = tokio::spawn(async move { + s.rpc() + .model() + .set_allowed_models(ModelSetAllowedModelsRequest { + allowed_models: Some(vec!["gpt-5".to_string(), "claude-sonnet-5".to_string()]), + }) + .await + }); + + let request = server.read_request().await; + assert_eq!(request["method"], "session.model.setAllowedModels"); + assert_eq!(request["params"]["sessionId"], server.session_id); + assert_eq!( + request["params"]["allowedModels"], + serde_json::json!(["gpt-5", "claude-sonnet-5"]) + ); + server + .respond( + &request, + serde_json::json!({ + "allowedModels": ["gpt-5", "claude-sonnet-5"], + "effectiveAllowedModels": ["gpt-5"], + "fallbackModel": "gpt-5", + "modelId": "gpt-5" + }), + ) + .await; + + let result = timeout(TIMEOUT, handle).await.unwrap().unwrap().unwrap(); + assert_eq!( + result.allowed_models.as_deref(), + Some(&["gpt-5".to_string(), "claude-sonnet-5".to_string()][..]) + ); + assert_eq!( + result.effective_allowed_models.as_deref(), + Some(&["gpt-5".to_string()][..]) + ); + assert_eq!(result.fallback_model.as_deref(), Some("gpt-5")); + assert_eq!(result.model_id.as_deref(), Some("gpt-5")); +} + #[tokio::test] async fn rpc_namespace_client_models_list_dispatches_correctly() { let (session, mut server) = create_session_pair().await; From 1b83eac1cda50b29f5e2247a0fd89fcea76b0c8e Mon Sep 17 00:00:00 2001 From: "John Bufe (he/him) (from Dev Box)" Date: Thu, 3 Sep 2026 22:30:09 -0400 Subject: [PATCH 2/2] Make Rust allowlist codegen reproducible Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- rust/src/wire.rs | 4 +- scripts/codegen/rust.ts | 114 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 114 insertions(+), 4 deletions(-) diff --git a/rust/src/wire.rs b/rust/src/wire.rs index 3a09bab77c..9775bfa54a 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -52,7 +52,7 @@ pub(crate) struct SessionCreateWire { pub session_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "allowedModels", skip_serializing_if = "Option::is_none")] pub allowed_models: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub client_name: Option, @@ -212,7 +212,7 @@ pub(crate) struct SessionResumeWire { pub session_id: SessionId, #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "allowedModels", skip_serializing_if = "Option::is_none")] pub allowed_models: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub client_name: Option, diff --git a/scripts/codegen/rust.ts b/scripts/codegen/rust.ts index b3cc5d5753..238817a743 100644 --- a/scripts/codegen/rust.ts +++ b/scripts/codegen/rust.ts @@ -69,6 +69,114 @@ const EXTERNAL_SCHEMA_RUST_TYPE_MODULE: Record> = }, }; +/** + * Add the live model-allowlist RPC until the pinned CLI schema includes it. + * + * This is Rust-only because the compatibility surface is currently exposed + * only by the Rust SDK. + */ +function addModelSetAllowedModelsToApiSchema(schema: ApiSchema): ApiSchema { + const session = (schema.session ??= {}); + const model = (session.model ??= {}) as Record; + if (model.setAllowedModels !== undefined) return schema; + + const definitions = (schema.definitions ??= {}); + if ( + definitions.ModelSetAllowedModelsRequest !== undefined || + definitions.ModelSetAllowedModelsResult !== undefined + ) { + throw new Error( + "Model allowlist schema definitions exist without session.model.setAllowedModels", + ); + } + + const allowedModelsProperty = { + anyOf: [ + { + type: "array", + items: { + type: "string", + }, + }, + { + type: "null", + }, + ], + description: "Exact model IDs to permit, or null to clear the host restriction.", + }; + const requestDescription = + "Host-supplied exact CAPI model IDs to allow for this running session. The runtime intersects the list with repository `.github/allowed_models.txt` policy. Omit or pass null to clear the host restriction; an explicit empty or disjoint list is rejected."; + + definitions.ModelSetAllowedModelsRequest = { + type: "object", + properties: { + allowedModels: allowedModelsProperty, + }, + additionalProperties: false, + description: requestDescription, + title: "ModelSetAllowedModelsRequest", + stability: "experimental", + } as JSONSchema7Definition; + definitions.ModelSetAllowedModelsResult = { + type: "object", + properties: { + allowedModels: { + type: "array", + items: { + type: "string", + }, + description: "Normalized host allowlist. Omitted when the host restriction was cleared.", + }, + effectiveAllowedModels: { + type: "array", + items: { + type: "string", + }, + description: + "Effective exact IDs or repository policy patterns after applying the host restriction. Omitted by relay clients whose AHP host applies the policy asynchronously.", + }, + fallbackModel: { + type: "string", + description: "Effective deterministic fallback model, when the policy defines one.", + }, + modelId: { + type: "string", + description: + "Selected session model after reconciling a now-disallowed concrete selection.", + }, + }, + additionalProperties: false, + description: "The applied host allowlist and effective session model policy after intersection.", + title: "ModelSetAllowedModelsResult", + } as JSONSchema7Definition; + model.setAllowedModels = { + rpcMethod: "session.model.setAllowedModels", + description: "Replaces or clears the host-supplied model allowlist for a running session.", + params: { + type: "object", + properties: { + sessionId: { + type: "string", + description: "Target session identifier", + }, + allowedModels: allowedModelsProperty, + }, + required: ["sessionId"], + additionalProperties: false, + description: requestDescription, + title: "ModelSetAllowedModelsRequest", + stability: "experimental", + }, + result: { + $ref: "#/definitions/ModelSetAllowedModelsResult", + description: "The applied host allowlist and effective session model policy after intersection.", + }, + stability: "experimental", + }; + + return schema; +} + function rustDeprecatedAttributes(indent = ""): string[] { return [`${indent}#[doc(hidden)]`, `${indent}#[deprecated]`]; } @@ -2219,8 +2327,10 @@ async function generate(): Promise { const sessionEventsRaw = normalizeSchemaBrandCasing( JSON.parse(await fs.readFile(sessionEventsSchemaPath, "utf-8")), ); - const apiRaw = normalizeSchemaBrandCasing( - JSON.parse(await fs.readFile(apiSchemaPath, "utf-8")) as ApiSchema, + const apiRaw = addModelSetAllowedModelsToApiSchema( + normalizeSchemaBrandCasing( + JSON.parse(await fs.readFile(apiSchemaPath, "utf-8")) as ApiSchema, + ), ); const sessionEventsSchema = propagateInternalVisibility(