diff --git a/rust/src/generated/api_types.rs b/rust/src/generated/api_types.rs index 1bd83a50f7..072bc6b956 100644 --- a/rust/src/generated/api_types.rs +++ b/rust/src/generated/api_types.rs @@ -296,6 +296,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` @@ -22088,6 +22090,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. /// ///
@@ -23642,6 +23685,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 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(