mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 21:03:26 +00:00
feat: model list
This commit is contained in:
parent
6b3a620fa4
commit
f54980d1da
24 changed files with 1174 additions and 52 deletions
|
|
@ -19,8 +19,8 @@ use crate::router::{
|
|||
PermissionReply, PermissionReplyRequest, QuestionReplyRequest,
|
||||
};
|
||||
use crate::router::{
|
||||
AgentListResponse, AgentModesResponse, CreateSessionResponse, EventsResponse,
|
||||
SessionListResponse,
|
||||
AgentListResponse, AgentModelsResponse, AgentModesResponse, CreateSessionResponse,
|
||||
EventsResponse, SessionListResponse,
|
||||
};
|
||||
use crate::server_logs::ServerLogs;
|
||||
use crate::telemetry;
|
||||
|
|
@ -228,6 +228,8 @@ pub enum AgentsCommand {
|
|||
Install(ApiInstallAgentArgs),
|
||||
/// Show available modes for an agent.
|
||||
Modes(AgentModesArgs),
|
||||
/// Show available models for an agent.
|
||||
Models(AgentModelsArgs),
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
|
|
@ -294,6 +296,13 @@ pub struct AgentModesArgs {
|
|||
client: ClientArgs,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
pub struct AgentModelsArgs {
|
||||
agent: String,
|
||||
#[command(flatten)]
|
||||
client: ClientArgs,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
pub struct CreateSessionArgs {
|
||||
session_id: String,
|
||||
|
|
@ -650,6 +659,12 @@ fn run_agents(command: &AgentsCommand, cli: &CliConfig) -> Result<(), CliError>
|
|||
let response = ctx.get(&path)?;
|
||||
print_json_response::<AgentModesResponse>(response)
|
||||
}
|
||||
AgentsCommand::Models(args) => {
|
||||
let ctx = ClientContext::new(cli, &args.client)?;
|
||||
let path = format!("{API_PREFIX}/agents/{}/models", args.agent);
|
||||
let response = ctx.get(&path)?;
|
||||
print_json_response::<AgentModelsResponse>(response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ use tokio::sync::{broadcast, Mutex};
|
|||
use tokio::time::interval;
|
||||
use utoipa::{IntoParams, OpenApi, ToSchema};
|
||||
|
||||
use crate::router::{AppState, CreateSessionRequest, PermissionReply};
|
||||
use crate::router::{AgentModelInfo, AppState, CreateSessionRequest, PermissionReply};
|
||||
use sandbox_agent_agent_management::agents::AgentId;
|
||||
use sandbox_agent_error::SandboxError;
|
||||
use sandbox_agent_universal_agent_schema::{
|
||||
|
|
@ -218,6 +218,19 @@ struct OpenCodeSessionRuntime {
|
|||
tool_args_by_call: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct OpenCodeModelEntry {
|
||||
agent: AgentId,
|
||||
model: AgentModelInfo,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct OpenCodeModelCache {
|
||||
entries: Vec<OpenCodeModelEntry>,
|
||||
model_lookup: HashMap<String, AgentId>,
|
||||
default_model: String,
|
||||
}
|
||||
|
||||
pub struct OpenCodeState {
|
||||
config: OpenCodeCompatConfig,
|
||||
default_project_id: String,
|
||||
|
|
@ -229,6 +242,7 @@ pub struct OpenCodeState {
|
|||
session_runtime: Mutex<HashMap<String, OpenCodeSessionRuntime>>,
|
||||
session_streams: Mutex<HashMap<String, bool>>,
|
||||
event_broadcaster: broadcast::Sender<Value>,
|
||||
model_cache: Mutex<Option<OpenCodeModelCache>>,
|
||||
}
|
||||
|
||||
impl OpenCodeState {
|
||||
|
|
@ -246,6 +260,7 @@ impl OpenCodeState {
|
|||
session_runtime: Mutex::new(HashMap::new()),
|
||||
session_streams: Mutex::new(HashMap::new()),
|
||||
event_broadcaster,
|
||||
model_cache: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -591,12 +606,88 @@ fn default_agent_mode() -> &'static str {
|
|||
OPENCODE_DEFAULT_AGENT_MODE
|
||||
}
|
||||
|
||||
fn resolve_agent_from_model(provider_id: &str, model_id: &str) -> Option<AgentId> {
|
||||
if provider_id == OPENCODE_PROVIDER_ID {
|
||||
AgentId::parse(model_id)
|
||||
} else {
|
||||
None
|
||||
async fn opencode_model_cache(state: &OpenCodeAppState) -> OpenCodeModelCache {
|
||||
{
|
||||
let cache = state.opencode.model_cache.lock().await;
|
||||
if let Some(cache) = cache.as_ref() {
|
||||
return cache.clone();
|
||||
}
|
||||
}
|
||||
|
||||
let cache = build_opencode_model_cache(state).await;
|
||||
let mut slot = state.opencode.model_cache.lock().await;
|
||||
*slot = Some(cache.clone());
|
||||
cache
|
||||
}
|
||||
|
||||
async fn build_opencode_model_cache(state: &OpenCodeAppState) -> OpenCodeModelCache {
|
||||
let mut entries = Vec::new();
|
||||
let mut model_lookup = HashMap::new();
|
||||
let mut default_model: Option<String> = None;
|
||||
|
||||
for agent in available_agent_ids() {
|
||||
let response = match state.inner.session_manager().agent_models(agent).await {
|
||||
Ok(response) => response,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if default_model.is_none() {
|
||||
default_model = response
|
||||
.default_model
|
||||
.clone()
|
||||
.or_else(|| response.models.first().map(|model| model.id.clone()));
|
||||
}
|
||||
|
||||
for model in response.models {
|
||||
let model_id = model.id.clone();
|
||||
if model_lookup.contains_key(&model_id) {
|
||||
continue;
|
||||
}
|
||||
model_lookup.insert(model_id.clone(), agent);
|
||||
entries.push(OpenCodeModelEntry { agent, model });
|
||||
}
|
||||
}
|
||||
|
||||
let default_model = if model_lookup.contains_key(OPENCODE_DEFAULT_MODEL_ID) {
|
||||
OPENCODE_DEFAULT_MODEL_ID.to_string()
|
||||
} else {
|
||||
default_model.unwrap_or_else(|| OPENCODE_DEFAULT_MODEL_ID.to_string())
|
||||
};
|
||||
|
||||
OpenCodeModelCache {
|
||||
entries,
|
||||
model_lookup,
|
||||
default_model,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_agent_from_model(
|
||||
cache: &OpenCodeModelCache,
|
||||
provider_id: &str,
|
||||
model_id: &str,
|
||||
) -> Option<AgentId> {
|
||||
if provider_id != OPENCODE_PROVIDER_ID {
|
||||
return None;
|
||||
}
|
||||
if let Some(agent) = cache.model_lookup.get(model_id) {
|
||||
return Some(*agent);
|
||||
}
|
||||
if let Some(agent) = AgentId::parse(model_id) {
|
||||
return Some(agent);
|
||||
}
|
||||
if model_id.contains('/') {
|
||||
return Some(AgentId::Opencode);
|
||||
}
|
||||
if model_id.starts_with("claude-") {
|
||||
return Some(AgentId::Claude);
|
||||
}
|
||||
if ["smart", "rush", "deep", "free"].contains(&model_id) {
|
||||
return Some(AgentId::Amp);
|
||||
}
|
||||
if model_id.starts_with("gpt-") || model_id.starts_with('o') {
|
||||
return Some(AgentId::Codex);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn normalize_agent_mode(agent: Option<String>) -> String {
|
||||
|
|
@ -611,19 +702,22 @@ async fn resolve_session_agent(
|
|||
requested_provider: Option<&str>,
|
||||
requested_model: Option<&str>,
|
||||
) -> (String, String, String) {
|
||||
let cache = opencode_model_cache(state).await;
|
||||
let default_model_id = cache.default_model.clone();
|
||||
let mut provider_id = requested_provider
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(OPENCODE_PROVIDER_ID)
|
||||
.to_string();
|
||||
let mut model_id = requested_model
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(OPENCODE_DEFAULT_MODEL_ID)
|
||||
.unwrap_or(default_model_id.as_str())
|
||||
.to_string();
|
||||
let mut resolved_agent = resolve_agent_from_model(&provider_id, &model_id);
|
||||
let mut resolved_agent = resolve_agent_from_model(&cache, &provider_id, &model_id);
|
||||
if resolved_agent.is_none() {
|
||||
provider_id = OPENCODE_PROVIDER_ID.to_string();
|
||||
model_id = OPENCODE_DEFAULT_MODEL_ID.to_string();
|
||||
resolved_agent = Some(default_agent_id());
|
||||
model_id = default_model_id.clone();
|
||||
resolved_agent =
|
||||
resolve_agent_from_model(&cache, &provider_id, &model_id).or_else(|| Some(default_agent_id()));
|
||||
}
|
||||
|
||||
let mut resolved_agent_id: Option<String> = None;
|
||||
|
|
@ -654,7 +748,7 @@ async fn resolve_session_agent(
|
|||
|
||||
fn agent_display_name(agent: AgentId) -> &'static str {
|
||||
match agent {
|
||||
AgentId::Claude => "Claude",
|
||||
AgentId::Claude => "Claude Code",
|
||||
AgentId::Codex => "Codex",
|
||||
AgentId::Opencode => "OpenCode",
|
||||
AgentId::Amp => "Amp",
|
||||
|
|
@ -662,17 +756,19 @@ fn agent_display_name(agent: AgentId) -> &'static str {
|
|||
}
|
||||
}
|
||||
|
||||
fn model_config_entry(agent: AgentId) -> Value {
|
||||
fn model_config_entry(agent: AgentId, model: &AgentModelInfo) -> Value {
|
||||
let model_name = model.name.clone().unwrap_or_else(|| model.id.clone());
|
||||
let variants = model_variants_object(model);
|
||||
json!({
|
||||
"id": agent.as_str(),
|
||||
"id": model.id,
|
||||
"providerID": OPENCODE_PROVIDER_ID,
|
||||
"api": {
|
||||
"id": "sandbox-agent",
|
||||
"url": "http://localhost",
|
||||
"npm": "@sandbox-agent/sdk"
|
||||
},
|
||||
"name": agent_display_name(agent),
|
||||
"family": "sandbox-agent",
|
||||
"name": model_name,
|
||||
"family": agent_display_name(agent),
|
||||
"capabilities": {
|
||||
"temperature": true,
|
||||
"reasoning": true,
|
||||
|
|
@ -707,14 +803,17 @@ fn model_config_entry(agent: AgentId) -> Value {
|
|||
"options": {},
|
||||
"headers": {},
|
||||
"release_date": "2024-01-01",
|
||||
"variants": {}
|
||||
"variants": variants
|
||||
})
|
||||
}
|
||||
|
||||
fn model_summary_entry(agent: AgentId) -> Value {
|
||||
fn model_summary_entry(agent: AgentId, model: &AgentModelInfo) -> Value {
|
||||
let model_name = model.name.clone().unwrap_or_else(|| model.id.clone());
|
||||
let variants = model_variants_object(model);
|
||||
json!({
|
||||
"id": agent.as_str(),
|
||||
"name": agent_display_name(agent),
|
||||
"id": model.id,
|
||||
"name": model_name,
|
||||
"family": agent_display_name(agent),
|
||||
"release_date": "2024-01-01",
|
||||
"attachment": false,
|
||||
"reasoning": true,
|
||||
|
|
@ -724,10 +823,22 @@ fn model_summary_entry(agent: AgentId) -> Value {
|
|||
"limit": {
|
||||
"context": 128000,
|
||||
"output": 4096
|
||||
}
|
||||
},
|
||||
"variants": variants
|
||||
})
|
||||
}
|
||||
|
||||
fn model_variants_object(model: &AgentModelInfo) -> Value {
|
||||
let Some(variants) = model.variants.as_ref() else {
|
||||
return json!({});
|
||||
};
|
||||
let mut map = serde_json::Map::new();
|
||||
for variant in variants {
|
||||
map.insert(variant.clone(), json!({}));
|
||||
}
|
||||
Value::Object(map)
|
||||
}
|
||||
|
||||
fn bad_request(message: &str) -> (StatusCode, Json<Value>) {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
|
|
@ -2351,10 +2462,16 @@ async fn oc_config_patch(Json(body): Json<Value>) -> impl IntoResponse {
|
|||
responses((status = 200)),
|
||||
tag = "opencode"
|
||||
)]
|
||||
async fn oc_config_providers() -> impl IntoResponse {
|
||||
async fn oc_config_providers(
|
||||
State(state): State<Arc<OpenCodeAppState>>,
|
||||
) -> impl IntoResponse {
|
||||
let cache = opencode_model_cache(&state).await;
|
||||
let mut models = serde_json::Map::new();
|
||||
for agent in available_agent_ids() {
|
||||
models.insert(agent.as_str().to_string(), model_config_entry(agent));
|
||||
for entry in &cache.entries {
|
||||
models.insert(
|
||||
entry.model.id.clone(),
|
||||
model_config_entry(entry.agent, &entry.model),
|
||||
);
|
||||
}
|
||||
let providers = json!({
|
||||
"providers": [
|
||||
|
|
@ -2369,7 +2486,7 @@ async fn oc_config_providers() -> impl IntoResponse {
|
|||
}
|
||||
],
|
||||
"default": {
|
||||
OPENCODE_PROVIDER_ID: OPENCODE_DEFAULT_MODEL_ID
|
||||
OPENCODE_PROVIDER_ID: cache.default_model
|
||||
}
|
||||
});
|
||||
(StatusCode::OK, Json(providers))
|
||||
|
|
@ -3648,10 +3765,16 @@ async fn oc_question_reject(
|
|||
responses((status = 200)),
|
||||
tag = "opencode"
|
||||
)]
|
||||
async fn oc_provider_list() -> impl IntoResponse {
|
||||
async fn oc_provider_list(
|
||||
State(state): State<Arc<OpenCodeAppState>>,
|
||||
) -> impl IntoResponse {
|
||||
let cache = opencode_model_cache(&state).await;
|
||||
let mut models = serde_json::Map::new();
|
||||
for agent in available_agent_ids() {
|
||||
models.insert(agent.as_str().to_string(), model_summary_entry(agent));
|
||||
for entry in &cache.entries {
|
||||
models.insert(
|
||||
entry.model.id.clone(),
|
||||
model_summary_entry(entry.agent, &entry.model),
|
||||
);
|
||||
}
|
||||
let providers = json!({
|
||||
"all": [
|
||||
|
|
@ -3663,7 +3786,7 @@ async fn oc_provider_list() -> impl IntoResponse {
|
|||
}
|
||||
],
|
||||
"default": {
|
||||
OPENCODE_PROVIDER_ID: OPENCODE_DEFAULT_MODEL_ID
|
||||
OPENCODE_PROVIDER_ID: cache.default_model
|
||||
},
|
||||
"connected": [OPENCODE_PROVIDER_ID]
|
||||
});
|
||||
|
|
|
|||
|
|
@ -46,11 +46,14 @@ use sandbox_agent_agent_management::agents::{
|
|||
AgentError as ManagerError, AgentId, AgentManager, InstallOptions, SpawnOptions, StreamingSpawn,
|
||||
};
|
||||
use sandbox_agent_agent_management::credentials::{
|
||||
extract_all_credentials, CredentialExtractionOptions, ExtractedCredentials,
|
||||
extract_all_credentials, AuthType, CredentialExtractionOptions, ExtractedCredentials,
|
||||
ProviderCredentials,
|
||||
};
|
||||
|
||||
const MOCK_EVENT_DELAY_MS: u64 = 200;
|
||||
static USER_MESSAGE_COUNTER: AtomicU64 = AtomicU64::new(1);
|
||||
const ANTHROPIC_MODELS_URL: &str = "https://api.anthropic.com/v1/models?beta=true";
|
||||
const ANTHROPIC_VERSION: &str = "2023-06-01";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AppState {
|
||||
|
|
@ -103,6 +106,7 @@ pub fn build_router_with_state(shared: Arc<AppState>) -> (Router, Arc<AppState>)
|
|||
.route("/agents", get(list_agents))
|
||||
.route("/agents/:agent/install", post(install_agent))
|
||||
.route("/agents/:agent/modes", get(get_agent_modes))
|
||||
.route("/agents/:agent/models", get(get_agent_models))
|
||||
.route("/sessions", get(list_sessions))
|
||||
.route("/sessions/:session_id", post(create_session))
|
||||
.route("/sessions/:session_id/messages", post(post_message))
|
||||
|
|
@ -218,6 +222,7 @@ pub async fn shutdown_servers(state: &Arc<AppState>) {
|
|||
get_health,
|
||||
install_agent,
|
||||
get_agent_modes,
|
||||
get_agent_models,
|
||||
list_agents,
|
||||
list_sessions,
|
||||
create_session,
|
||||
|
|
@ -235,6 +240,8 @@ pub async fn shutdown_servers(state: &Arc<AppState>) {
|
|||
AgentInstallRequest,
|
||||
AgentModeInfo,
|
||||
AgentModesResponse,
|
||||
AgentModelInfo,
|
||||
AgentModelsResponse,
|
||||
AgentCapabilities,
|
||||
AgentInfo,
|
||||
AgentListResponse,
|
||||
|
|
@ -1703,6 +1710,25 @@ impl SessionManager {
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn agent_models(
|
||||
self: &Arc<Self>,
|
||||
agent: AgentId,
|
||||
) -> Result<AgentModelsResponse, SandboxError> {
|
||||
match agent {
|
||||
AgentId::Claude => self.fetch_claude_models().await,
|
||||
AgentId::Codex => self.fetch_codex_models().await,
|
||||
AgentId::Opencode => match self.fetch_opencode_models().await {
|
||||
Ok(models) => Ok(models),
|
||||
Err(_) => Ok(AgentModelsResponse {
|
||||
models: Vec::new(),
|
||||
default_model: None,
|
||||
}),
|
||||
},
|
||||
AgentId::Amp => Ok(amp_models_response()),
|
||||
AgentId::Mock => Ok(mock_models_response()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn send_message(
|
||||
self: &Arc<Self>,
|
||||
session_id: String,
|
||||
|
|
@ -3155,7 +3181,7 @@ impl SessionManager {
|
|||
approval_policy: codex_approval_policy(Some(&session.permission_mode)),
|
||||
collaboration_mode: None,
|
||||
cwd: None,
|
||||
effort: None,
|
||||
effort: codex_effort_from_variant(session.variant.as_deref()),
|
||||
input: vec![codex_schema::UserInput::Text {
|
||||
text: prompt_text,
|
||||
text_elements: Vec::new(),
|
||||
|
|
@ -3213,6 +3239,254 @@ impl SessionManager {
|
|||
})
|
||||
}
|
||||
|
||||
async fn fetch_claude_models(&self) -> Result<AgentModelsResponse, SandboxError> {
|
||||
let credentials = self.extract_credentials().await?;
|
||||
let Some(cred) = credentials.anthropic else {
|
||||
return Ok(AgentModelsResponse {
|
||||
models: Vec::new(),
|
||||
default_model: None,
|
||||
});
|
||||
};
|
||||
|
||||
let headers = build_anthropic_headers(&cred)?;
|
||||
let response = self
|
||||
.http_client
|
||||
.get(ANTHROPIC_MODELS_URL)
|
||||
.headers(headers)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| SandboxError::StreamError {
|
||||
message: err.to_string(),
|
||||
})?;
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(SandboxError::StreamError {
|
||||
message: format!("Anthropic models request failed {status}: {body}"),
|
||||
});
|
||||
}
|
||||
|
||||
let value: Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|err| SandboxError::StreamError {
|
||||
message: err.to_string(),
|
||||
})?;
|
||||
let data = value
|
||||
.get("data")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut models = Vec::new();
|
||||
let mut default_model: Option<String> = None;
|
||||
let mut default_created: Option<String> = None;
|
||||
for item in data {
|
||||
let Some(id) = item.get("id").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
let name = item
|
||||
.get("display_name")
|
||||
.and_then(Value::as_str)
|
||||
.map(|value| value.to_string());
|
||||
let created = item
|
||||
.get("created_at")
|
||||
.and_then(Value::as_str)
|
||||
.map(|value| value.to_string());
|
||||
if let Some(created) = created.as_ref() {
|
||||
let should_update = match default_created.as_deref() {
|
||||
Some(current) => created.as_str() > current,
|
||||
None => true,
|
||||
};
|
||||
if should_update {
|
||||
default_created = Some(created.clone());
|
||||
default_model = Some(id.to_string());
|
||||
}
|
||||
}
|
||||
models.push(AgentModelInfo {
|
||||
id: id.to_string(),
|
||||
name,
|
||||
variants: None,
|
||||
default_variant: None,
|
||||
});
|
||||
}
|
||||
models.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
if default_model.is_none() {
|
||||
default_model = models.first().map(|model| model.id.clone());
|
||||
}
|
||||
|
||||
Ok(AgentModelsResponse {
|
||||
models,
|
||||
default_model,
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_codex_models(self: &Arc<Self>) -> Result<AgentModelsResponse, SandboxError> {
|
||||
let server = self.ensure_codex_server().await?;
|
||||
let mut models: Vec<AgentModelInfo> = Vec::new();
|
||||
let mut default_model: Option<String> = None;
|
||||
let mut seen = HashSet::new();
|
||||
let mut cursor: Option<String> = None;
|
||||
|
||||
loop {
|
||||
let id = server.next_request_id();
|
||||
let request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"method": "model/list",
|
||||
"params": {
|
||||
"cursor": cursor,
|
||||
"limit": null
|
||||
}
|
||||
});
|
||||
let rx = server
|
||||
.send_request(id, &request)
|
||||
.ok_or_else(|| SandboxError::StreamError {
|
||||
message: "failed to send model/list request".to_string(),
|
||||
})?;
|
||||
|
||||
let result = tokio::time::timeout(Duration::from_secs(30), rx).await;
|
||||
let value = match result {
|
||||
Ok(Ok(value)) => value,
|
||||
Ok(Err(_)) => {
|
||||
return Err(SandboxError::StreamError {
|
||||
message: "model/list request cancelled".to_string(),
|
||||
})
|
||||
}
|
||||
Err(_) => {
|
||||
return Err(SandboxError::StreamError {
|
||||
message: "model/list request timed out".to_string(),
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
let data = value
|
||||
.get("data")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
for item in data {
|
||||
let model_id = item
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| item.get("id").and_then(Value::as_str));
|
||||
let Some(model_id) = model_id else {
|
||||
continue;
|
||||
};
|
||||
if !seen.insert(model_id.to_string()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let name = item
|
||||
.get("displayName")
|
||||
.and_then(Value::as_str)
|
||||
.map(|value| value.to_string());
|
||||
let default_variant = item
|
||||
.get("defaultReasoningEffort")
|
||||
.and_then(Value::as_str)
|
||||
.map(|value| value.to_string());
|
||||
let mut variants: Vec<String> = item
|
||||
.get("supportedReasoningEfforts")
|
||||
.and_then(Value::as_array)
|
||||
.map(|values| {
|
||||
values
|
||||
.iter()
|
||||
.filter_map(|value| {
|
||||
value
|
||||
.get("reasoningEffort")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| value.as_str())
|
||||
.map(|entry| entry.to_string())
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if variants.is_empty() {
|
||||
variants = codex_variants();
|
||||
}
|
||||
variants.sort();
|
||||
variants.dedup();
|
||||
|
||||
if default_model.is_none()
|
||||
&& item
|
||||
.get("isDefault")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
default_model = Some(model_id.to_string());
|
||||
}
|
||||
|
||||
models.push(AgentModelInfo {
|
||||
id: model_id.to_string(),
|
||||
name,
|
||||
variants: Some(variants),
|
||||
default_variant,
|
||||
});
|
||||
}
|
||||
|
||||
let next_cursor = value
|
||||
.get("nextCursor")
|
||||
.and_then(Value::as_str)
|
||||
.map(|value| value.to_string());
|
||||
if next_cursor.is_none() {
|
||||
break;
|
||||
}
|
||||
cursor = next_cursor;
|
||||
}
|
||||
|
||||
models.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
if default_model.is_none() {
|
||||
default_model = models.first().map(|model| model.id.clone());
|
||||
}
|
||||
|
||||
Ok(AgentModelsResponse {
|
||||
models,
|
||||
default_model,
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_opencode_models(&self) -> Result<AgentModelsResponse, SandboxError> {
|
||||
let base_url = self.ensure_opencode_server().await?;
|
||||
let endpoints = [
|
||||
format!("{base_url}/config/providers"),
|
||||
format!("{base_url}/provider"),
|
||||
];
|
||||
for url in endpoints {
|
||||
let response = self.http_client.get(&url).send().await;
|
||||
let response = match response {
|
||||
Ok(response) => response,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if !response.status().is_success() {
|
||||
continue;
|
||||
}
|
||||
let value: Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|err| SandboxError::StreamError {
|
||||
message: err.to_string(),
|
||||
})?;
|
||||
if let Some(models) = parse_opencode_models(&value) {
|
||||
return Ok(models);
|
||||
}
|
||||
}
|
||||
Err(SandboxError::StreamError {
|
||||
message: "OpenCode models unavailable".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn extract_credentials(&self) -> Result<ExtractedCredentials, SandboxError> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let options = CredentialExtractionOptions::new();
|
||||
extract_all_credentials(&options)
|
||||
})
|
||||
.await
|
||||
.map_err(|err| SandboxError::StreamError {
|
||||
message: err.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn create_opencode_session(&self) -> Result<String, SandboxError> {
|
||||
let base_url = self.ensure_opencode_server().await?;
|
||||
let url = format!("{base_url}/session");
|
||||
|
|
@ -3479,6 +3753,26 @@ pub struct AgentModesResponse {
|
|||
pub modes: Vec<AgentModeInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentModelInfo {
|
||||
pub id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub variants: Option<Vec<String>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default_variant: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentModelsResponse {
|
||||
pub models: Vec<AgentModelInfo>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default_model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentCapabilities {
|
||||
|
|
@ -3500,6 +3794,7 @@ pub struct AgentCapabilities {
|
|||
pub mcp_tools: bool,
|
||||
pub streaming_deltas: bool,
|
||||
pub item_started: bool,
|
||||
pub variants: bool,
|
||||
/// Whether this agent uses a shared long-running server process (vs per-turn subprocess)
|
||||
pub shared_process: bool,
|
||||
}
|
||||
|
|
@ -3733,6 +4028,25 @@ async fn get_agent_modes(
|
|||
Ok(Json(AgentModesResponse { modes }))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v1/agents/{agent}/models",
|
||||
responses(
|
||||
(status = 200, body = AgentModelsResponse),
|
||||
(status = 400, body = ProblemDetails)
|
||||
),
|
||||
params(("agent" = String, Path, description = "Agent id")),
|
||||
tag = "agents"
|
||||
)]
|
||||
async fn get_agent_models(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(agent): Path<String>,
|
||||
) -> Result<Json<AgentModelsResponse>, ApiError> {
|
||||
let agent_id = parse_agent_id(&agent)?;
|
||||
let models = state.session_manager.agent_models(agent_id).await?;
|
||||
Ok(Json(models))
|
||||
}
|
||||
|
||||
const SERVER_INFO: &str = "\
|
||||
This is a Sandbox Agent server. Available endpoints:\n\
|
||||
- GET / - Server info\n\
|
||||
|
|
@ -4133,6 +4447,7 @@ fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities {
|
|||
mcp_tools: false,
|
||||
streaming_deltas: true,
|
||||
item_started: false,
|
||||
variants: false,
|
||||
shared_process: false, // per-turn subprocess with --resume
|
||||
},
|
||||
AgentId::Codex => AgentCapabilities {
|
||||
|
|
@ -4153,6 +4468,7 @@ fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities {
|
|||
mcp_tools: true,
|
||||
streaming_deltas: true,
|
||||
item_started: true,
|
||||
variants: true,
|
||||
shared_process: true, // shared app-server via JSON-RPC
|
||||
},
|
||||
AgentId::Opencode => AgentCapabilities {
|
||||
|
|
@ -4173,6 +4489,7 @@ fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities {
|
|||
mcp_tools: false,
|
||||
streaming_deltas: true,
|
||||
item_started: true,
|
||||
variants: true,
|
||||
shared_process: true, // shared HTTP server
|
||||
},
|
||||
AgentId::Amp => AgentCapabilities {
|
||||
|
|
@ -4193,6 +4510,7 @@ fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities {
|
|||
mcp_tools: false,
|
||||
streaming_deltas: false,
|
||||
item_started: false,
|
||||
variants: true,
|
||||
shared_process: false, // per-turn subprocess with --continue
|
||||
},
|
||||
AgentId::Mock => AgentCapabilities {
|
||||
|
|
@ -4213,6 +4531,7 @@ fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities {
|
|||
mcp_tools: true,
|
||||
streaming_deltas: true,
|
||||
item_started: true,
|
||||
variants: false,
|
||||
shared_process: false, // in-memory mock (no subprocess)
|
||||
},
|
||||
}
|
||||
|
|
@ -4287,6 +4606,118 @@ fn agent_modes_for(agent: AgentId) -> Vec<AgentModeInfo> {
|
|||
}
|
||||
}
|
||||
|
||||
fn amp_models_response() -> AgentModelsResponse {
|
||||
// NOTE: Amp models are hardcoded based on ampcode.com manual:
|
||||
// - smart
|
||||
// - rush
|
||||
// - deep
|
||||
// - free
|
||||
let models = ["smart", "rush", "deep", "free"]
|
||||
.into_iter()
|
||||
.map(|id| AgentModelInfo {
|
||||
id: id.to_string(),
|
||||
name: None,
|
||||
variants: Some(amp_variants()),
|
||||
default_variant: Some("medium".to_string()),
|
||||
})
|
||||
.collect();
|
||||
AgentModelsResponse {
|
||||
models,
|
||||
default_model: Some("smart".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn mock_models_response() -> AgentModelsResponse {
|
||||
AgentModelsResponse {
|
||||
models: vec![AgentModelInfo {
|
||||
id: "mock".to_string(),
|
||||
name: Some("Mock".to_string()),
|
||||
variants: None,
|
||||
default_variant: None,
|
||||
}],
|
||||
default_model: Some("mock".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn amp_variants() -> Vec<String> {
|
||||
vec!["medium", "high", "xhigh"]
|
||||
.into_iter()
|
||||
.map(|value| value.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn codex_variants() -> Vec<String> {
|
||||
vec!["none", "minimal", "low", "medium", "high", "xhigh"]
|
||||
.into_iter()
|
||||
.map(|value| value.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_opencode_models(value: &Value) -> Option<AgentModelsResponse> {
|
||||
let providers = value
|
||||
.get("providers")
|
||||
.and_then(Value::as_array)
|
||||
.or_else(|| value.get("all").and_then(Value::as_array))?;
|
||||
let default_map = value
|
||||
.get("default")
|
||||
.and_then(Value::as_object)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut models = Vec::new();
|
||||
let mut provider_order = Vec::new();
|
||||
for provider in providers {
|
||||
let provider_id = provider.get("id").and_then(Value::as_str)?;
|
||||
provider_order.push(provider_id.to_string());
|
||||
let Some(model_map) = provider.get("models").and_then(Value::as_object) else {
|
||||
continue;
|
||||
};
|
||||
for (key, model) in model_map {
|
||||
let model_id = model
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(key.as_str());
|
||||
let name = model
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.map(|value| value.to_string());
|
||||
let mut variants = model
|
||||
.get("variants")
|
||||
.and_then(Value::as_object)
|
||||
.map(|map| map.keys().cloned().collect::<Vec<_>>());
|
||||
if let Some(variants) = variants.as_mut() {
|
||||
variants.sort();
|
||||
}
|
||||
models.push(AgentModelInfo {
|
||||
id: format!("{provider_id}/{model_id}"),
|
||||
name,
|
||||
variants,
|
||||
default_variant: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
models.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
|
||||
let mut default_model = None;
|
||||
for provider_id in provider_order {
|
||||
if let Some(model_id) = default_map
|
||||
.get(&provider_id)
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
default_model = Some(format!("{provider_id}/{model_id}"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
if default_model.is_none() {
|
||||
default_model = models.first().map(|model| model.id.clone());
|
||||
}
|
||||
|
||||
Some(AgentModelsResponse {
|
||||
models,
|
||||
default_model,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_agent_mode(agent: AgentId, agent_mode: Option<&str>) -> Result<String, SandboxError> {
|
||||
let mode = agent_mode.unwrap_or("build");
|
||||
match agent {
|
||||
|
|
@ -4590,6 +5021,7 @@ struct CodexAppServerState {
|
|||
next_id: i64,
|
||||
prompt: String,
|
||||
model: Option<String>,
|
||||
effort: Option<codex_schema::ReasoningEffort>,
|
||||
cwd: Option<String>,
|
||||
approval_policy: Option<codex_schema::AskForApproval>,
|
||||
sandbox_mode: Option<codex_schema::SandboxMode>,
|
||||
|
|
@ -4614,6 +5046,7 @@ impl CodexAppServerState {
|
|||
next_id: 1,
|
||||
prompt,
|
||||
model: options.model.clone(),
|
||||
effort: codex_effort_from_variant(options.variant.as_deref()),
|
||||
cwd,
|
||||
approval_policy: codex_approval_policy(options.permission_mode.as_deref()),
|
||||
sandbox_mode: codex_sandbox_mode(options.permission_mode.as_deref()),
|
||||
|
|
@ -4859,7 +5292,7 @@ impl CodexAppServerState {
|
|||
approval_policy: self.approval_policy,
|
||||
collaboration_mode: None,
|
||||
cwd: self.cwd.clone(),
|
||||
effort: None,
|
||||
effort: self.effort.clone(),
|
||||
input: vec![codex_schema::UserInput::Text {
|
||||
text: self.prompt.clone(),
|
||||
text_elements: Vec::new(),
|
||||
|
|
@ -4902,6 +5335,15 @@ fn codex_prompt_for_mode(prompt: &str, mode: Option<&str>) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
fn codex_effort_from_variant(variant: Option<&str>) -> Option<codex_schema::ReasoningEffort> {
|
||||
let variant = variant?.trim();
|
||||
if variant.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let normalized = variant.to_lowercase();
|
||||
serde_json::from_value(Value::String(normalized)).ok()
|
||||
}
|
||||
|
||||
fn codex_approval_policy(mode: Option<&str>) -> Option<codex_schema::AskForApproval> {
|
||||
match mode {
|
||||
Some("plan") => Some(codex_schema::AskForApproval::Untrusted),
|
||||
|
|
@ -6497,3 +6939,35 @@ pub fn add_token_header(headers: &mut HeaderMap, token: &str) {
|
|||
headers.insert(axum::http::header::AUTHORIZATION, header);
|
||||
}
|
||||
}
|
||||
|
||||
fn build_anthropic_headers(
|
||||
credentials: &ProviderCredentials,
|
||||
) -> Result<reqwest::header::HeaderMap, SandboxError> {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
match credentials.auth_type {
|
||||
AuthType::ApiKey => {
|
||||
let value =
|
||||
reqwest::header::HeaderValue::from_str(&credentials.api_key).map_err(|_| {
|
||||
SandboxError::StreamError {
|
||||
message: "invalid anthropic api key header".to_string(),
|
||||
}
|
||||
})?;
|
||||
headers.insert("x-api-key", value);
|
||||
}
|
||||
AuthType::Oauth => {
|
||||
let value = format!("Bearer {}", credentials.api_key);
|
||||
let header =
|
||||
reqwest::header::HeaderValue::from_str(&value).map_err(|_| {
|
||||
SandboxError::StreamError {
|
||||
message: "invalid anthropic oauth header".to_string(),
|
||||
}
|
||||
})?;
|
||||
headers.insert(reqwest::header::AUTHORIZATION, header);
|
||||
}
|
||||
}
|
||||
headers.insert(
|
||||
"anthropic-version",
|
||||
reqwest::header::HeaderValue::from_static(ANTHROPIC_VERSION),
|
||||
);
|
||||
Ok(headers)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -736,6 +736,81 @@ fn normalize_agent_modes(value: &Value) -> Value {
|
|||
json!({ "modes": normalized })
|
||||
}
|
||||
|
||||
fn normalize_agent_models(value: &Value, agent: AgentId) -> Value {
|
||||
let models = value
|
||||
.get("models")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let default_model = value.get("defaultModel").and_then(Value::as_str);
|
||||
|
||||
let mut map = Map::new();
|
||||
let model_count = models.len();
|
||||
map.insert("nonEmpty".to_string(), Value::Bool(model_count > 0));
|
||||
map.insert("hasDefault".to_string(), Value::Bool(default_model.is_some()));
|
||||
let default_in_list = default_model.map_or(false, |default_id| {
|
||||
models
|
||||
.iter()
|
||||
.any(|model| model.get("id").and_then(Value::as_str) == Some(default_id))
|
||||
});
|
||||
map.insert(
|
||||
"defaultInList".to_string(),
|
||||
Value::Bool(default_in_list),
|
||||
);
|
||||
let has_variants = models.iter().any(|model| {
|
||||
model
|
||||
.get("variants")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|variants| !variants.is_empty())
|
||||
});
|
||||
match agent {
|
||||
AgentId::Claude | AgentId::Opencode => {
|
||||
map.insert(
|
||||
"hasVariants".to_string(),
|
||||
Value::String("<redacted>".to_string()),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
map.insert("hasVariants".to_string(), Value::Bool(has_variants));
|
||||
}
|
||||
}
|
||||
|
||||
if matches!(agent, AgentId::Amp | AgentId::Mock) {
|
||||
map.insert(
|
||||
"modelCount".to_string(),
|
||||
Value::Number(model_count.into()),
|
||||
);
|
||||
let mut ids: Vec<String> = models
|
||||
.iter()
|
||||
.filter_map(|model| model.get("id").and_then(Value::as_str).map(|id| id.to_string()))
|
||||
.collect();
|
||||
ids.sort();
|
||||
map.insert("ids".to_string(), json!(ids));
|
||||
if let Some(default_model) = default_model {
|
||||
map.insert(
|
||||
"defaultModel".to_string(),
|
||||
Value::String(default_model.to_string()),
|
||||
);
|
||||
}
|
||||
if agent == AgentId::Amp {
|
||||
if let Some(variants) = models
|
||||
.first()
|
||||
.and_then(|model| model.get("variants"))
|
||||
.and_then(Value::as_array)
|
||||
{
|
||||
let mut variant_ids: Vec<String> = variants
|
||||
.iter()
|
||||
.filter_map(|variant| variant.as_str().map(|id| id.to_string()))
|
||||
.collect();
|
||||
variant_ids.sort();
|
||||
map.insert("variants".to_string(), json!(variant_ids));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Value::Object(map)
|
||||
}
|
||||
|
||||
fn normalize_sessions(value: &Value) -> Value {
|
||||
let sessions = value
|
||||
.get("sessions")
|
||||
|
|
|
|||
|
|
@ -162,4 +162,27 @@ async fn agent_endpoints_snapshots() {
|
|||
insta::assert_yaml_snapshot!(normalize_agent_modes(&modes));
|
||||
});
|
||||
}
|
||||
|
||||
for config in &configs {
|
||||
let _guard = apply_credentials(&config.credentials);
|
||||
let (status, models) = send_json(
|
||||
&app.app,
|
||||
Method::GET,
|
||||
&format!("/v1/agents/{}/models", config.agent.as_str()),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "agent models");
|
||||
let model_count = models
|
||||
.get("models")
|
||||
.and_then(|value| value.as_array())
|
||||
.map(|models| models.len())
|
||||
.unwrap_or_default();
|
||||
assert!(model_count > 0, "agent models should not be empty");
|
||||
insta::with_settings!({
|
||||
snapshot_suffix => snapshot_name("agent_models", Some(config.agent)),
|
||||
}, {
|
||||
insta::assert_yaml_snapshot!(normalize_agent_models(&models, config.agent));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
source: server/packages/sandbox-agent/tests/http_sse_snapshots.rs
|
||||
expression: normalize_agent_models(&models, config.agent)
|
||||
---
|
||||
nonEmpty: true
|
||||
hasDefault: true
|
||||
defaultInList: true
|
||||
hasVariants: true
|
||||
modelCount: 4
|
||||
ids:
|
||||
- deep
|
||||
- free
|
||||
- rush
|
||||
- smart
|
||||
defaultModel: smart
|
||||
variants:
|
||||
- high
|
||||
- medium
|
||||
- xhigh
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
source: server/packages/sandbox-agent/tests/http_sse_snapshots.rs
|
||||
expression: normalize_agent_models(&models, config.agent)
|
||||
---
|
||||
nonEmpty: true
|
||||
hasDefault: true
|
||||
defaultInList: true
|
||||
hasVariants: "<redacted>"
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
source: server/packages/sandbox-agent/tests/http_sse_snapshots.rs
|
||||
expression: normalize_agent_models(&models, config.agent)
|
||||
---
|
||||
nonEmpty: true
|
||||
hasDefault: true
|
||||
defaultInList: true
|
||||
hasVariants: true
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
source: server/packages/sandbox-agent/tests/http_sse_snapshots.rs
|
||||
expression: normalize_agent_models(&models, config.agent)
|
||||
---
|
||||
nonEmpty: true
|
||||
hasDefault: true
|
||||
defaultInList: true
|
||||
hasVariants: false
|
||||
modelCount: 1
|
||||
ids:
|
||||
- mock
|
||||
defaultModel: mock
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
source: server/packages/sandbox-agent/tests/http_sse_snapshots.rs
|
||||
expression: normalize_agent_models(&models, config.agent)
|
||||
---
|
||||
nonEmpty: true
|
||||
hasDefault: true
|
||||
defaultInList: true
|
||||
hasVariants: "<redacted>"
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* Tests for OpenCode-compatible provider/model listing.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterEach, beforeEach } from "vitest";
|
||||
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk";
|
||||
import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn";
|
||||
|
||||
describe("OpenCode-compatible Model API", () => {
|
||||
let handle: SandboxAgentHandle;
|
||||
let client: OpencodeClient;
|
||||
|
||||
beforeAll(async () => {
|
||||
await buildSandboxAgent();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
handle = await spawnSandboxAgent({ opencodeCompat: true });
|
||||
client = createOpencodeClient({
|
||||
baseUrl: `${handle.baseUrl}/opencode`,
|
||||
headers: { Authorization: `Bearer ${handle.token}` },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await handle?.dispose();
|
||||
});
|
||||
|
||||
it("should list models grouped by agent with real model IDs", async () => {
|
||||
const response = await client.provider.list();
|
||||
const provider = response.data?.all?.find((entry) => entry.id === "sandbox-agent");
|
||||
expect(provider).toBeDefined();
|
||||
|
||||
const models = provider?.models ?? {};
|
||||
const modelIds = Object.keys(models);
|
||||
expect(modelIds.length).toBeGreaterThan(0);
|
||||
|
||||
expect(models["mock"]).toBeDefined();
|
||||
expect(models["mock"].id).toBe("mock");
|
||||
expect(models["mock"].family).toBe("Mock");
|
||||
|
||||
expect(models["smart"]).toBeDefined();
|
||||
expect(models["smart"].id).toBe("smart");
|
||||
expect(models["smart"].family).toBe("Amp");
|
||||
|
||||
expect(models["amp"]).toBeUndefined();
|
||||
expect(response.data?.default?.["sandbox-agent"]).toBe("mock");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue