diff --git a/rust/src/generated/rpc.rs b/rust/src/generated/rpc.rs
index 50eee0f1fb..d6d25d93a2 100644
--- a/rust/src/generated/rpc.rs
+++ b/rust/src/generated/rpc.rs
@@ -7717,6 +7717,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 a99f00a19f..d67aaa6948 100644
--- a/rust/src/types.rs
+++ b/rust/src/types.rs
@@ -1944,6 +1944,11 @@ pub struct SessionConfig {
pub session_id: Option,
/// Model to use (e.g. `"gpt-4"`, `"claude-sonnet-4"`).
pub model: Option,
+ /// Exact model IDs this session may use. When unset, the host imposes no
+ /// model restriction. The runtime validates configured IDs, rejects an
+ /// explicit empty list, and intersects the list with applicable model
+ /// policies.
+ pub allowed_models: Option>,
/// Application name sent as `User-Agent` context.
pub client_name: Option,
/// Reasoning effort level (e.g. `"low"`, `"medium"`, `"high"`).
@@ -2295,6 +2300,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)
@@ -2438,6 +2444,7 @@ impl Default for SessionConfig {
Self {
session_id: None,
model: None,
+ allowed_models: None,
client_name: None,
reasoning_effort: None,
reasoning_summary: None,
@@ -2608,6 +2615,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,
@@ -2831,6 +2839,19 @@ impl SessionConfig {
self
}
+ /// Restrict this session to the provided exact model IDs.
+ ///
+ /// Passing an empty iterator sends an explicit empty list, which the
+ /// runtime rejects.
+ pub fn with_allowed_models(mut self, models: I) -> Self
+ where
+ I: IntoIterator- ,
+ S: Into,
+ {
+ self.allowed_models = Some(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());
@@ -3383,6 +3404,11 @@ 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 IDs the resumed session may use. When unset, the host
+ /// imposes no model restriction. The runtime validates configured IDs,
+ /// rejects an explicit empty list, and intersects the list with applicable
+ /// model policies.
+ pub allowed_models: Option>,
/// Application name sent as User-Agent context.
pub client_name: Option,
/// Desired reasoning effort to apply after resuming the session.
@@ -3647,6 +3673,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)
@@ -3833,6 +3860,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,
@@ -3941,6 +3969,7 @@ impl ResumeSessionConfig {
Self {
session_id,
model: None,
+ allowed_models: None,
client_name: None,
reasoning_effort: None,
reasoning_summary: None,
@@ -4135,6 +4164,19 @@ impl ResumeSessionConfig {
self
}
+ /// Restrict the resumed session to the provided exact model IDs.
+ ///
+ /// Passing an empty iterator sends an explicit empty list, which the
+ /// runtime rejects.
+ pub fn with_allowed_models(mut self, models: I) -> Self
+ where
+ I: IntoIterator
- ,
+ S: Into,
+ {
+ self.allowed_models = Some(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());
@@ -6401,6 +6443,7 @@ mod tests {
fn session_config_default_wire_flags_off_without_handlers() {
let cfg = SessionConfig::default();
assert_eq!(cfg.mcp_oauth_token_storage, None);
+ assert_eq!(cfg.allowed_models, None);
// Wire flags are derived from handler presence at create_session
// time, not stored on the config. With no handlers installed, every
// request_* flag should serialize as false.
@@ -6416,12 +6459,14 @@ mod tests {
assert!(!wire.request_mcp_apps);
let json = serde_json::to_value(&wire).unwrap();
assert!(json.get("askUserVariant").is_none());
+ assert!(json.get("allowedModels").is_none());
}
#[test]
fn resume_session_config_new_wire_flags_off_without_handlers() {
let cfg = ResumeSessionConfig::new(SessionId::from("resume-flags"));
assert_eq!(cfg.mcp_oauth_token_storage, None);
+ assert_eq!(cfg.allowed_models, None);
let (wire, _runtime) = cfg
.into_wire()
.expect("default resume config has no duplicate handlers");
@@ -6434,6 +6479,43 @@ mod tests {
assert!(!wire.request_mcp_apps);
let json = serde_json::to_value(&wire).unwrap();
assert!(json.get("askUserVariant").is_none());
+ assert!(json.get("allowedModels").is_none());
+ }
+
+ #[test]
+ fn session_configs_build_debug_and_serialize_allowed_models() {
+ let create = SessionConfig::default().with_allowed_models(["gpt-5.4", "claude-sonnet-4"]);
+ assert_eq!(
+ create.allowed_models.as_deref(),
+ Some(&["gpt-5.4".to_string(), "claude-sonnet-4".to_string()][..])
+ );
+ assert!(format!("{create:?}").contains("allowed_models"));
+
+ let (create_wire, _) = create
+ .into_wire(Some(SessionId::from("create-allowed-models")))
+ .expect("allowed model config has no duplicate handlers");
+ let create_json = serde_json::to_value(&create_wire).unwrap();
+ assert_eq!(
+ create_json["allowedModels"],
+ json!(["gpt-5.4", "claude-sonnet-4"])
+ );
+
+ let resume = ResumeSessionConfig::new(SessionId::from("resume-allowed-models"))
+ .with_allowed_models(vec!["gpt-5.4".to_string(), "gpt-5-mini".to_string()]);
+ assert_eq!(
+ resume.allowed_models.as_deref(),
+ Some(&["gpt-5.4".to_string(), "gpt-5-mini".to_string()][..])
+ );
+ assert!(format!("{resume:?}").contains("allowed_models"));
+
+ let (resume_wire, _) = resume
+ .into_wire()
+ .expect("resume allowed model config has no duplicate handlers");
+ let resume_json = serde_json::to_value(&resume_wire).unwrap();
+ assert_eq!(
+ resume_json["allowedModels"],
+ json!(["gpt-5.4", "gpt-5-mini"])
+ );
}
#[test]
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/api_types_test.rs b/rust/tests/api_types_test.rs
index 9429a2bb6f..7d0946e5f0 100644
--- a/rust/tests/api_types_test.rs
+++ b/rust/tests/api_types_test.rs
@@ -5,9 +5,10 @@
use github_copilot_sdk::rpc::{
Extension, ExtensionList, ExtensionSource, ExtensionStatus, ExtensionsDisableRequest,
- ExtensionsEnableRequest, FleetStartRequest, FleetStartResult, ModelSwitchAutoTierRequest,
- ModelSwitchAutoTierResult, ModelSwitchAutoTierStatus, QueuePendingItems, QueuePendingItemsKind,
- SandboxConfig, SendAgentMode, TasksStartAgentRequest,
+ ExtensionsEnableRequest, FleetStartRequest, FleetStartResult, ModelSetAllowedModelsRequest,
+ ModelSetAllowedModelsResult, ModelSwitchAutoTierRequest, ModelSwitchAutoTierResult,
+ ModelSwitchAutoTierStatus, QueuePendingItems, QueuePendingItemsKind, SandboxConfig,
+ SendAgentMode, TasksStartAgentRequest,
};
use github_copilot_sdk::session_events::{
PermissionRequest, PermissionRequestedData, SessionEventData, TypedSessionEvent,
@@ -129,6 +130,42 @@ fn tasks_start_agent_request_fields_are_accessible() {
assert_eq!(request.description.as_deref(), Some("SDK task agent"));
}
+#[test]
+fn model_allowed_models_request_and_result_preserve_contract_fields() {
+ let replace = ModelSetAllowedModelsRequest {
+ allowed_models: Some(vec!["gpt-5.4".to_string(), "gpt-5-mini".to_string()]),
+ };
+ assert_eq!(
+ serde_json::to_value(&replace).unwrap(),
+ serde_json::json!({ "allowedModels": ["gpt-5.4", "gpt-5-mini"] })
+ );
+
+ let clear = ModelSetAllowedModelsRequest::default();
+ assert_eq!(clear.allowed_models, None);
+ assert_eq!(serde_json::to_value(&clear).unwrap(), serde_json::json!({}));
+
+ let explicit_null: ModelSetAllowedModelsRequest =
+ serde_json::from_value(serde_json::json!({ "allowedModels": null })).unwrap();
+ assert_eq!(explicit_null.allowed_models, None);
+
+ let result = ModelSetAllowedModelsResult {
+ allowed_models: Some(vec!["gpt-5.4".to_string()]),
+ effective_allowed_models: Some(vec!["gpt-5.4".to_string()]),
+ fallback_model: Some("gpt-5.4".to_string()),
+ model_id: Some("gpt-5.4".to_string()),
+ };
+ assert_eq!(
+ result.allowed_models.as_deref(),
+ Some(["gpt-5.4".to_string()].as_slice())
+ );
+ assert_eq!(
+ result.effective_allowed_models.as_deref(),
+ Some(["gpt-5.4".to_string()].as_slice())
+ );
+ assert_eq!(result.fallback_model.as_deref(), Some("gpt-5.4"));
+ assert_eq!(result.model_id.as_deref(), Some("gpt-5.4"));
+}
+
#[test]
fn permission_event_exposes_managed_approval_required() {
let data: PermissionRequestedData = serde_json::from_value(serde_json::json!({
diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs
index ad4c8abe42..9480e7669e 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, OpenCanvasInstance,
};
use github_copilot_sdk::session_events::{
ManagedSettingsResolvedSource, McpOauthRequiredData, ReasoningSummary, SessionLimitsConfig,
@@ -1919,6 +1919,83 @@ async fn session_rpc_methods_send_correct_method_names() {
}
}
+#[tokio::test]
+async fn session_model_set_allowed_models_replaces_and_clears_the_allowlist() {
+ let (session, mut server) = create_session_pair().await;
+ let session = Arc::new(session);
+
+ let replace_handle = tokio::spawn({
+ let session = session.clone();
+ async move {
+ session
+ .rpc()
+ .model()
+ .set_allowed_models(ModelSetAllowedModelsRequest {
+ allowed_models: Some(vec!["gpt-5.4".to_string(), "gpt-5-mini".to_string()]),
+ })
+ .await
+ }
+ });
+ let replace_request = server.read_request().await;
+ assert_eq!(replace_request["method"], "session.model.setAllowedModels");
+ assert_eq!(replace_request["params"]["sessionId"], server.session_id);
+ assert_eq!(
+ replace_request["params"]["allowedModels"],
+ serde_json::json!(["gpt-5.4", "gpt-5-mini"])
+ );
+ server
+ .respond(
+ &replace_request,
+ serde_json::json!({
+ "allowedModels": ["gpt-5.4", "gpt-5-mini"],
+ "effectiveAllowedModels": ["gpt-5.4"],
+ "fallbackModel": "gpt-5.4",
+ "modelId": "gpt-5.4"
+ }),
+ )
+ .await;
+ let replace_result = timeout(TIMEOUT, replace_handle)
+ .await
+ .unwrap()
+ .unwrap()
+ .unwrap();
+ assert_eq!(
+ replace_result.allowed_models.as_deref(),
+ Some(&["gpt-5.4".to_string(), "gpt-5-mini".to_string()][..])
+ );
+ assert_eq!(
+ replace_result.effective_allowed_models.as_deref(),
+ Some(&["gpt-5.4".to_string()][..])
+ );
+ assert_eq!(replace_result.fallback_model.as_deref(), Some("gpt-5.4"));
+ assert_eq!(replace_result.model_id.as_deref(), Some("gpt-5.4"));
+
+ let clear_handle = tokio::spawn({
+ let session = session.clone();
+ async move {
+ session
+ .rpc()
+ .model()
+ .set_allowed_models(ModelSetAllowedModelsRequest::default())
+ .await
+ }
+ });
+ let clear_request = server.read_request().await;
+ assert_eq!(clear_request["method"], "session.model.setAllowedModels");
+ assert_eq!(clear_request["params"]["sessionId"], server.session_id);
+ assert!(clear_request["params"].get("allowedModels").is_none());
+ server.respond(&clear_request, serde_json::json!({})).await;
+ let clear_result = timeout(TIMEOUT, clear_handle)
+ .await
+ .unwrap()
+ .unwrap()
+ .unwrap();
+ assert_eq!(clear_result.allowed_models, None);
+ assert_eq!(clear_result.effective_allowed_models, None);
+ assert_eq!(clear_result.fallback_model, None);
+ assert_eq!(clear_result.model_id, None);
+}
+
#[tokio::test]
async fn client_rpc_methods_send_correct_method_names() {
let (client, mut server_read, mut server_write) = make_client();
diff --git a/scripts/codegen/rust.ts b/scripts/codegen/rust.ts
index b3cc5d5753..ad0153b484 100644
--- a/scripts/codegen/rust.ts
+++ b/scripts/codegen/rust.ts
@@ -69,6 +69,91 @@ const EXTERNAL_SCHEMA_RUST_TYPE_MODULE: Record> =
},
};
+/** Add the live allowlist RPC until the pinned CLI schema includes the paired runtime contract. */
+function addModelSetAllowedModelsRpc(schema: ApiSchema): ApiSchema {
+ 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.";
+ const resultDescription =
+ "The applied host allowlist and effective session model policy after intersection.";
+ const allowedModelsProperty: JSONSchema7 = {
+ anyOf: [
+ {
+ type: "array",
+ items: { type: "string" },
+ },
+ { type: "null" },
+ ],
+ description: "Exact model IDs to permit, or null to clear the host restriction.",
+ };
+ const requestDefinition: JSONSchema7 = {
+ type: "object",
+ properties: {
+ allowedModels: allowedModelsProperty,
+ },
+ additionalProperties: false,
+ description: requestDescription,
+ title: "ModelSetAllowedModelsRequest",
+ };
+ const resultDefinition: JSONSchema7 = {
+ 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: resultDescription,
+ title: "ModelSetAllowedModelsResult",
+ };
+
+ const session = (schema.session ??= {});
+ const model = (session.model ??= {}) as Record;
+ model.setAllowedModels ??= {
+ rpcMethod: "session.model.setAllowedModels",
+ description: "Replaces or clears the host-supplied model allowlist for a running session.",
+ params: {
+ ...requestDefinition,
+ properties: {
+ sessionId: {
+ type: "string",
+ description: "Target session identifier",
+ },
+ allowedModels: allowedModelsProperty,
+ },
+ required: ["sessionId"],
+ stability: "experimental",
+ },
+ result: {
+ $ref: "#/definitions/ModelSetAllowedModelsResult",
+ description: resultDescription,
+ },
+ stability: "experimental",
+ } satisfies RpcMethod;
+
+ const definitions = (schema.definitions ??= {});
+ definitions.ModelSetAllowedModelsRequest ??= requestDefinition;
+ definitions.ModelSetAllowedModelsResult ??= resultDefinition;
+
+ return schema;
+}
+
function rustDeprecatedAttributes(indent = ""): string[] {
return [`${indent}#[doc(hidden)]`, `${indent}#[deprecated]`];
}
@@ -2219,8 +2304,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 = addModelSetAllowedModelsRpc(
+ normalizeSchemaBrandCasing(
+ JSON.parse(await fs.readFile(apiSchemaPath, "utf-8")) as ApiSchema,
+ ),
);
const sessionEventsSchema = propagateInternalVisibility(