feat: model list

This commit is contained in:
Nathan Flurry 2026-02-06 00:51:35 -08:00
parent 6b3a620fa4
commit f54980d1da
24 changed files with 1174 additions and 52 deletions

View file

@ -53,6 +53,7 @@ Universal schema guidance:
- `sandbox-agent api agents list``GET /v1/agents`
- `sandbox-agent api agents install``POST /v1/agents/{agent}/install`
- `sandbox-agent api agents modes``GET /v1/agents/{agent}/modes`
- `sandbox-agent api agents models``GET /v1/agents/{agent}/models`
- `sandbox-agent api sessions list``GET /v1/sessions`
- `sandbox-agent api sessions create``POST /v1/sessions/{sessionId}`
- `sandbox-agent api sessions send-message``POST /v1/sessions/{sessionId}/messages`

View file

@ -217,6 +217,16 @@ sandbox-agent api agents modes <AGENT>
sandbox-agent api agents modes claude
```
#### Get Agent Models
```bash
sandbox-agent api agents models <AGENT>
```
```bash
sandbox-agent api agents models claude
```
---
### Sessions
@ -377,6 +387,7 @@ sandbox-agent api sessions reply-permission my-session perm1 --reply once
| `api agents list` | `GET /v1/agents` |
| `api agents install` | `POST /v1/agents/{agent}/install` |
| `api agents modes` | `GET /v1/agents/{agent}/modes` |
| `api agents models` | `GET /v1/agents/{agent}/models` |
| `api sessions list` | `GET /v1/sessions` |
| `api sessions create` | `POST /v1/sessions/{sessionId}` |
| `api sessions send-message` | `POST /v1/sessions/{sessionId}/messages` |

View file

@ -102,6 +102,47 @@
}
}
},
"/v1/agents/{agent}/models": {
"get": {
"tags": [
"agents"
],
"operationId": "get_agent_models",
"parameters": [
{
"name": "agent",
"in": "path",
"description": "Agent id",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AgentModelsResponse"
}
}
}
},
"400": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/v1/agents/{agent}/modes": {
"get": {
"tags": [
@ -669,6 +710,7 @@
"mcpTools",
"streamingDeltas",
"itemStarted",
"variants",
"sharedProcess"
],
"properties": {
@ -726,6 +768,9 @@
},
"toolResults": {
"type": "boolean"
},
"variants": {
"type": "boolean"
}
}
},
@ -832,6 +877,50 @@
}
}
},
"AgentModelInfo": {
"type": "object",
"required": [
"id"
],
"properties": {
"defaultVariant": {
"type": "string",
"nullable": true
},
"id": {
"type": "string"
},
"name": {
"type": "string",
"nullable": true
},
"variants": {
"type": "array",
"items": {
"type": "string"
},
"nullable": true
}
}
},
"AgentModelsResponse": {
"type": "object",
"required": [
"models"
],
"properties": {
"defaultModel": {
"type": "string",
"nullable": true
},
"models": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AgentModelInfo"
}
}
}
},
"AgentModesResponse": {
"type": "object",
"required": [

View file

@ -29,6 +29,7 @@ This table shows which agent feature coverage appears in the universal event str
| File Changes | - | ✓ | - | - |
| MCP Tools | - | ✓ | - | - |
| Streaming Deltas | ✓ | ✓ | ✓ | - |
| Variants | | ✓ | ✓ | ✓ |
Agents: [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview) · [Codex](https://github.com/openai/codex) · [OpenCode](https://github.com/opencode-ai/opencode) · [Amp](https://ampcode.com)
@ -76,6 +77,9 @@ Agents: [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude
<Accordion title="Streaming Deltas">
Native streaming of content deltas. When not supported, the daemon emits a single synthetic delta before `item.completed`.
</Accordion>
<Accordion title="Variants">
Model variants such as reasoning effort or depth. Agents may expose different variant sets per model.
</Accordion>
</AccordionGroup>
Want support for another agent? [Open an issue](https://github.com/rivet-dev/sandbox-agent/issues/new) to request it.

View file

@ -3,6 +3,7 @@ import {
SandboxAgentError,
SandboxAgent,
type AgentInfo,
type AgentModelInfo,
type AgentModeInfo,
type PermissionEventData,
type QuestionEventData,
@ -89,6 +90,8 @@ export default function App() {
const [agents, setAgents] = useState<AgentInfo[]>([]);
const [modesByAgent, setModesByAgent] = useState<Record<string, AgentModeInfo[]>>({});
const [modelsByAgent, setModelsByAgent] = useState<Record<string, AgentModelInfo[]>>({});
const [defaultModelByAgent, setDefaultModelByAgent] = useState<Record<string, string>>({});
const [sessions, setSessions] = useState<SessionInfo[]>([]);
const [agentsLoading, setAgentsLoading] = useState(false);
const [agentsError, setAgentsError] = useState<string | null>(null);
@ -96,6 +99,8 @@ export default function App() {
const [sessionsError, setSessionsError] = useState<string | null>(null);
const [modesLoadingByAgent, setModesLoadingByAgent] = useState<Record<string, boolean>>({});
const [modesErrorByAgent, setModesErrorByAgent] = useState<Record<string, string | null>>({});
const [modelsLoadingByAgent, setModelsLoadingByAgent] = useState<Record<string, boolean>>({});
const [modelsErrorByAgent, setModelsErrorByAgent] = useState<Record<string, string | null>>({});
const [agentId, setAgentId] = useState("claude");
const [agentMode, setAgentMode] = useState("");
@ -252,10 +257,14 @@ export default function App() {
stopTurnStream();
setAgents([]);
setSessions([]);
setModelsByAgent({});
setDefaultModelByAgent({});
setAgentsLoading(false);
setSessionsLoading(false);
setAgentsError(null);
setSessionsError(null);
setModelsLoadingByAgent({});
setModelsErrorByAgent({});
};
const refreshAgents = async () => {
@ -268,6 +277,7 @@ export default function App() {
for (const agent of agentList) {
if (agent.installed) {
loadModes(agent.id);
loadModels(agent.id);
}
}
} catch (error) {
@ -314,6 +324,29 @@ export default function App() {
}
};
const loadModels = async (targetId: string) => {
setModelsLoadingByAgent((prev) => ({ ...prev, [targetId]: true }));
setModelsErrorByAgent((prev) => ({ ...prev, [targetId]: null }));
try {
const data = await getClient().getAgentModels(targetId);
const models = data.models ?? [];
setModelsByAgent((prev) => ({ ...prev, [targetId]: models }));
if (data.defaultModel) {
setDefaultModelByAgent((prev) => ({ ...prev, [targetId]: data.defaultModel! }));
} else {
setDefaultModelByAgent((prev) => {
const next = { ...prev };
delete next[targetId];
return next;
});
}
} catch {
setModelsErrorByAgent((prev) => ({ ...prev, [targetId]: "Unable to load models." }));
} finally {
setModelsLoadingByAgent((prev) => ({ ...prev, [targetId]: false }));
}
};
const sendMessage = async () => {
const prompt = message.trim();
if (!prompt || !sessionId || turnStreaming) return;
@ -825,6 +858,12 @@ export default function App() {
}
}, [connected, agentId]);
useEffect(() => {
if (connected && agentId && !modelsByAgent[agentId]) {
loadModels(agentId);
}
}, [connected, agentId]);
useEffect(() => {
const modes = modesByAgent[agentId];
if (modes && modes.length > 0 && !agentMode) {
@ -836,6 +875,15 @@ export default function App() {
const activeModes = modesByAgent[agentId] ?? [];
const modesLoading = modesLoadingByAgent[agentId] ?? false;
const modesError = modesErrorByAgent[agentId] ?? null;
const modelOptions = modelsByAgent[agentId] ?? [];
const modelsLoading = modelsLoadingByAgent[agentId] ?? false;
const modelsError = modelsErrorByAgent[agentId] ?? null;
const defaultModel = defaultModelByAgent[agentId] ?? "";
const selectedModelId = model || defaultModel;
const selectedModel = modelOptions.find((entry) => entry.id === selectedModelId);
const variantOptions = selectedModel?.variants ?? [];
const defaultVariant = selectedModel?.defaultVariant ?? "";
const supportsVariants = Boolean(currentAgent?.capabilities?.variants);
const agentDisplayNames: Record<string, string> = {
claude: "Claude Code",
codex: "Codex",
@ -936,6 +984,13 @@ export default function App() {
permissionMode={permissionMode}
model={model}
variant={variant}
modelOptions={modelOptions}
defaultModel={defaultModel}
modelsLoading={modelsLoading}
modelsError={modelsError}
variantOptions={variantOptions}
defaultVariant={defaultVariant}
supportsVariants={supportsVariants}
streamMode={streamMode}
activeModes={activeModes}
currentAgentVersion={currentAgent?.version ?? null}

View file

@ -10,6 +10,7 @@ import {
GitBranch,
HelpCircle,
Image,
Layers,
MessageSquare,
Paperclip,
PlayCircle,
@ -37,7 +38,8 @@ const badges = [
{ key: "fileChanges", label: "File Changes", icon: FileDiff },
{ key: "mcpTools", label: "MCP", icon: Plug },
{ key: "streamingDeltas", label: "Deltas", icon: Activity },
{ key: "itemStarted", label: "Item Start", icon: CircleDot }
{ key: "itemStarted", label: "Item Start", icon: CircleDot },
{ key: "variants", label: "Variants", icon: Layers }
] as const;
type BadgeItem = (typeof badges)[number];

View file

@ -1,6 +1,6 @@
import { MessageSquare, PauseCircle, PlayCircle, Plus, Square, Terminal } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { AgentInfo, AgentModeInfo, PermissionEventData, QuestionEventData } from "sandbox-agent";
import type { AgentInfo, AgentModelInfo, AgentModeInfo, PermissionEventData, QuestionEventData } from "sandbox-agent";
import ApprovalsTab from "../debug/ApprovalsTab";
import ChatInput from "./ChatInput";
import ChatMessages from "./ChatMessages";
@ -28,6 +28,13 @@ const ChatPanel = ({
permissionMode,
model,
variant,
modelOptions,
defaultModel,
modelsLoading,
modelsError,
variantOptions,
defaultVariant,
supportsVariants,
streamMode,
activeModes,
currentAgentVersion,
@ -70,6 +77,13 @@ const ChatPanel = ({
permissionMode: string;
model: string;
variant: string;
modelOptions: AgentModelInfo[];
defaultModel: string;
modelsLoading: boolean;
modelsError: string | null;
variantOptions: string[];
defaultVariant: string;
supportsVariants: boolean;
streamMode: "poll" | "sse" | "turn";
activeModes: AgentModeInfo[];
currentAgentVersion?: string | null;
@ -277,6 +291,13 @@ const ChatPanel = ({
permissionMode={permissionMode}
model={model}
variant={variant}
modelOptions={modelOptions}
defaultModel={defaultModel}
modelsLoading={modelsLoading}
modelsError={modelsError}
variantOptions={variantOptions}
defaultVariant={defaultVariant}
supportsVariants={supportsVariants}
activeModes={activeModes}
modesLoading={modesLoading}
modesError={modesError}

View file

@ -1,10 +1,17 @@
import type { AgentModeInfo } from "sandbox-agent";
import type { AgentModelInfo, AgentModeInfo } from "sandbox-agent";
const ChatSetup = ({
agentMode,
permissionMode,
model,
variant,
modelOptions,
defaultModel,
modelsLoading,
modelsError,
variantOptions,
defaultVariant,
supportsVariants,
activeModes,
hasSession,
modesLoading,
@ -18,6 +25,13 @@ const ChatSetup = ({
permissionMode: string;
model: string;
variant: string;
modelOptions: AgentModelInfo[];
defaultModel: string;
modelsLoading: boolean;
modelsError: string | null;
variantOptions: string[];
defaultVariant: string;
supportsVariants: boolean;
activeModes: AgentModeInfo[];
hasSession: boolean;
modesLoading: boolean;
@ -27,6 +41,16 @@ const ChatSetup = ({
onModelChange: (value: string) => void;
onVariantChange: (value: string) => void;
}) => {
const showModelSelect = modelsLoading || Boolean(modelsError) || modelOptions.length > 0;
const hasModelOptions = modelOptions.length > 0;
const showVariantSelect =
supportsVariants && (modelsLoading || Boolean(modelsError) || variantOptions.length > 0);
const hasVariantOptions = variantOptions.length > 0;
const modelCustom =
model && hasModelOptions && !modelOptions.some((entry) => entry.id === model);
const variantCustom =
variant && hasVariantOptions && !variantOptions.includes(variant);
return (
<div className="setup-row">
<div className="setup-field">
@ -71,26 +95,82 @@ const ChatSetup = ({
<div className="setup-field">
<span className="setup-label">Model</span>
<input
className="setup-input"
value={model}
onChange={(e) => onModelChange(e.target.value)}
placeholder="Model"
title="Model"
disabled={!hasSession}
/>
{showModelSelect ? (
<select
className="setup-select"
value={model}
onChange={(e) => onModelChange(e.target.value)}
title="Model"
disabled={!hasSession || modelsLoading || Boolean(modelsError)}
>
{modelsLoading ? (
<option value="">Loading models...</option>
) : modelsError ? (
<option value="">{modelsError}</option>
) : (
<>
<option value="">
{defaultModel ? `Default (${defaultModel})` : "Default"}
</option>
{modelCustom && <option value={model}>{model} (custom)</option>}
{modelOptions.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.name ?? entry.id}
</option>
))}
</>
)}
</select>
) : (
<input
className="setup-input"
value={model}
onChange={(e) => onModelChange(e.target.value)}
placeholder="Model"
title="Model"
disabled={!hasSession}
/>
)}
</div>
<div className="setup-field">
<span className="setup-label">Variant</span>
<input
className="setup-input"
value={variant}
onChange={(e) => onVariantChange(e.target.value)}
placeholder="Variant"
title="Variant"
disabled={!hasSession}
/>
{showVariantSelect ? (
<select
className="setup-select"
value={variant}
onChange={(e) => onVariantChange(e.target.value)}
title="Variant"
disabled={!hasSession || !supportsVariants || modelsLoading || Boolean(modelsError)}
>
{modelsLoading ? (
<option value="">Loading variants...</option>
) : modelsError ? (
<option value="">{modelsError}</option>
) : (
<>
<option value="">
{defaultVariant ? `Default (${defaultVariant})` : "Default"}
</option>
{variantCustom && <option value={variant}>{variant} (custom)</option>}
{variantOptions.map((entry) => (
<option key={entry} value={entry}>
{entry}
</option>
))}
</>
)}
</select>
) : (
<input
className="setup-input"
value={variant}
onChange={(e) => onVariantChange(e.target.value)}
placeholder={supportsVariants ? "Variant" : "Variants unsupported"}
title="Variant"
disabled={!hasSession || !supportsVariants}
/>
)}
</div>
</div>
);

View file

@ -14,6 +14,7 @@ export type FeatureCoverageView = AgentCapabilities & {
mcpTools?: boolean;
streamingDeltas?: boolean;
itemStarted?: boolean;
variants?: boolean;
};
export const emptyFeatureCoverage: FeatureCoverageView = {
@ -34,5 +35,6 @@ export const emptyFeatureCoverage: FeatureCoverageView = {
mcpTools: false,
streamingDeltas: false,
itemStarted: false,
variants: false,
sharedProcess: false
};

View file

@ -2,6 +2,7 @@ import type { SandboxAgentSpawnHandle, SandboxAgentSpawnOptions } from "./spawn.
import type {
AgentInstallRequest,
AgentListResponse,
AgentModelsResponse,
AgentModesResponse,
CreateSessionRequest,
CreateSessionResponse,
@ -113,6 +114,10 @@ export class SandboxAgent {
return this.requestJson("GET", `${API_PREFIX}/agents/${encodeURIComponent(agent)}/modes`);
}
async getAgentModels(agent: string): Promise<AgentModelsResponse> {
return this.requestJson("GET", `${API_PREFIX}/agents/${encodeURIComponent(agent)}/models`);
}
async createSession(sessionId: string, request: CreateSessionRequest): Promise<CreateSessionResponse> {
return this.requestJson("POST", `${API_PREFIX}/sessions/${encodeURIComponent(sessionId)}`, {
body: request,

View file

@ -11,6 +11,9 @@ export interface paths {
"/v1/agents/{agent}/install": {
post: operations["install_agent"];
};
"/v1/agents/{agent}/models": {
get: operations["get_agent_models"];
};
"/v1/agents/{agent}/modes": {
get: operations["get_agent_modes"];
};
@ -73,6 +76,7 @@ export interface components {
textMessages: boolean;
toolCalls: boolean;
toolResults: boolean;
variants: boolean;
};
AgentError: {
agent?: string | null;
@ -100,6 +104,16 @@ export interface components {
id: string;
name: string;
};
AgentModelInfo: {
defaultVariant?: string | null;
id: string;
name?: string | null;
variants?: string[] | null;
};
AgentModelsResponse: {
defaultModel?: string | null;
models: components["schemas"]["AgentModelInfo"][];
};
AgentModesResponse: {
modes: components["schemas"]["AgentModeInfo"][];
};
@ -383,6 +397,26 @@ export interface operations {
};
};
};
get_agent_models: {
parameters: {
path: {
/** @description Agent id */
agent: string;
};
};
responses: {
200: {
content: {
"application/json": components["schemas"]["AgentModelsResponse"];
};
};
400: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
};
};
get_agent_modes: {
parameters: {
path: {

View file

@ -10,6 +10,8 @@ export type {
AgentInfo,
AgentInstallRequest,
AgentListResponse,
AgentModelInfo,
AgentModelsResponse,
AgentModeInfo,
AgentModesResponse,
AgentUnparsedData,

View file

@ -6,6 +6,8 @@ export type AgentCapabilities = S["AgentCapabilities"];
export type AgentInfo = S["AgentInfo"];
export type AgentInstallRequest = S["AgentInstallRequest"];
export type AgentListResponse = S["AgentListResponse"];
export type AgentModelInfo = S["AgentModelInfo"];
export type AgentModelsResponse = S["AgentModelsResponse"];
export type AgentModeInfo = S["AgentModeInfo"];
export type AgentModesResponse = S["AgentModesResponse"];
export type AgentUnparsedData = S["AgentUnparsedData"];

View file

@ -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)
}
}
}

View file

@ -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]
});

View file

@ -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)
}

View file

@ -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")

View file

@ -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));
});
}
}

View file

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

View file

@ -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>"

View file

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

View file

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

View file

@ -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>"

View file

@ -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");
});
});