diff --git a/CLAUDE.md b/CLAUDE.md index f5e0e45..5eec975 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` diff --git a/docs/cli.mdx b/docs/cli.mdx index 5f0c47d..b01f4e4 100644 --- a/docs/cli.mdx +++ b/docs/cli.mdx @@ -217,6 +217,16 @@ sandbox-agent api agents modes sandbox-agent api agents modes claude ``` +#### Get Agent Models + +```bash +sandbox-agent api agents models +``` + +```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` | diff --git a/docs/openapi.json b/docs/openapi.json index 69a809a..d422563 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -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": [ diff --git a/docs/session-transcript-schema.mdx b/docs/session-transcript-schema.mdx index a5a0158..3abc82f 100644 --- a/docs/session-transcript-schema.mdx +++ b/docs/session-transcript-schema.mdx @@ -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 Native streaming of content deltas. When not supported, the daemon emits a single synthetic delta before `item.completed`. + + Model variants such as reasoning effort or depth. Agents may expose different variant sets per model. + Want support for another agent? [Open an issue](https://github.com/rivet-dev/sandbox-agent/issues/new) to request it. diff --git a/frontend/packages/inspector/src/App.tsx b/frontend/packages/inspector/src/App.tsx index cd4e118..5429dcd 100644 --- a/frontend/packages/inspector/src/App.tsx +++ b/frontend/packages/inspector/src/App.tsx @@ -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([]); const [modesByAgent, setModesByAgent] = useState>({}); + const [modelsByAgent, setModelsByAgent] = useState>({}); + const [defaultModelByAgent, setDefaultModelByAgent] = useState>({}); const [sessions, setSessions] = useState([]); const [agentsLoading, setAgentsLoading] = useState(false); const [agentsError, setAgentsError] = useState(null); @@ -96,6 +99,8 @@ export default function App() { const [sessionsError, setSessionsError] = useState(null); const [modesLoadingByAgent, setModesLoadingByAgent] = useState>({}); const [modesErrorByAgent, setModesErrorByAgent] = useState>({}); + const [modelsLoadingByAgent, setModelsLoadingByAgent] = useState>({}); + const [modelsErrorByAgent, setModelsErrorByAgent] = useState>({}); 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 = { 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} diff --git a/frontend/packages/inspector/src/components/agents/FeatureCoverageBadges.tsx b/frontend/packages/inspector/src/components/agents/FeatureCoverageBadges.tsx index 59ebb92..0ff41d0 100644 --- a/frontend/packages/inspector/src/components/agents/FeatureCoverageBadges.tsx +++ b/frontend/packages/inspector/src/components/agents/FeatureCoverageBadges.tsx @@ -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]; diff --git a/frontend/packages/inspector/src/components/chat/ChatPanel.tsx b/frontend/packages/inspector/src/components/chat/ChatPanel.tsx index 4faea51..e6c1f9e 100644 --- a/frontend/packages/inspector/src/components/chat/ChatPanel.tsx +++ b/frontend/packages/inspector/src/components/chat/ChatPanel.tsx @@ -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} diff --git a/frontend/packages/inspector/src/components/chat/ChatSetup.tsx b/frontend/packages/inspector/src/components/chat/ChatSetup.tsx index ded3825..c04bb6b 100644 --- a/frontend/packages/inspector/src/components/chat/ChatSetup.tsx +++ b/frontend/packages/inspector/src/components/chat/ChatSetup.tsx @@ -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 (
@@ -71,26 +95,82 @@ const ChatSetup = ({
Model - onModelChange(e.target.value)} - placeholder="Model" - title="Model" - disabled={!hasSession} - /> + {showModelSelect ? ( + + ) : ( + onModelChange(e.target.value)} + placeholder="Model" + title="Model" + disabled={!hasSession} + /> + )}
Variant - onVariantChange(e.target.value)} - placeholder="Variant" - title="Variant" - disabled={!hasSession} - /> + {showVariantSelect ? ( + + ) : ( + onVariantChange(e.target.value)} + placeholder={supportsVariants ? "Variant" : "Variants unsupported"} + title="Variant" + disabled={!hasSession || !supportsVariants} + /> + )}
); diff --git a/frontend/packages/inspector/src/types/agents.ts b/frontend/packages/inspector/src/types/agents.ts index 80e7e5b..4a1b9f6 100644 --- a/frontend/packages/inspector/src/types/agents.ts +++ b/frontend/packages/inspector/src/types/agents.ts @@ -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 }; diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts index 7f9ad95..f290406 100644 --- a/sdks/typescript/src/client.ts +++ b/sdks/typescript/src/client.ts @@ -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 { + return this.requestJson("GET", `${API_PREFIX}/agents/${encodeURIComponent(agent)}/models`); + } + async createSession(sessionId: string, request: CreateSessionRequest): Promise { return this.requestJson("POST", `${API_PREFIX}/sessions/${encodeURIComponent(sessionId)}`, { body: request, diff --git a/sdks/typescript/src/generated/openapi.ts b/sdks/typescript/src/generated/openapi.ts index 52816ad..1e3239e 100644 --- a/sdks/typescript/src/generated/openapi.ts +++ b/sdks/typescript/src/generated/openapi.ts @@ -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: { diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts index db8b4eb..1d5d349 100644 --- a/sdks/typescript/src/index.ts +++ b/sdks/typescript/src/index.ts @@ -10,6 +10,8 @@ export type { AgentInfo, AgentInstallRequest, AgentListResponse, + AgentModelInfo, + AgentModelsResponse, AgentModeInfo, AgentModesResponse, AgentUnparsedData, diff --git a/sdks/typescript/src/types.ts b/sdks/typescript/src/types.ts index e0c43df..350df6b 100644 --- a/sdks/typescript/src/types.ts +++ b/sdks/typescript/src/types.ts @@ -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"]; diff --git a/server/packages/sandbox-agent/src/cli.rs b/server/packages/sandbox-agent/src/cli.rs index b0a40d7..724468c 100644 --- a/server/packages/sandbox-agent/src/cli.rs +++ b/server/packages/sandbox-agent/src/cli.rs @@ -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::(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::(response) + } } } diff --git a/server/packages/sandbox-agent/src/opencode_compat.rs b/server/packages/sandbox-agent/src/opencode_compat.rs index 6fbcd9e..982cdcc 100644 --- a/server/packages/sandbox-agent/src/opencode_compat.rs +++ b/server/packages/sandbox-agent/src/opencode_compat.rs @@ -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, } +#[derive(Clone, Debug)] +struct OpenCodeModelEntry { + agent: AgentId, + model: AgentModelInfo, +} + +#[derive(Clone, Debug)] +struct OpenCodeModelCache { + entries: Vec, + model_lookup: HashMap, + default_model: String, +} + pub struct OpenCodeState { config: OpenCodeCompatConfig, default_project_id: String, @@ -229,6 +242,7 @@ pub struct OpenCodeState { session_runtime: Mutex>, session_streams: Mutex>, event_broadcaster: broadcast::Sender, + model_cache: Mutex>, } 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 { - 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 = 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 { + 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 { @@ -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 = 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) { ( StatusCode::BAD_REQUEST, @@ -2351,10 +2462,16 @@ async fn oc_config_patch(Json(body): Json) -> impl IntoResponse { responses((status = 200)), tag = "opencode" )] -async fn oc_config_providers() -> impl IntoResponse { +async fn oc_config_providers( + State(state): State>, +) -> 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>, +) -> 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] }); diff --git a/server/packages/sandbox-agent/src/router.rs b/server/packages/sandbox-agent/src/router.rs index 06434a9..8ac6e25 100644 --- a/server/packages/sandbox-agent/src/router.rs +++ b/server/packages/sandbox-agent/src/router.rs @@ -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) -> (Router, Arc) .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) { 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) { AgentInstallRequest, AgentModeInfo, AgentModesResponse, + AgentModelInfo, + AgentModelsResponse, AgentCapabilities, AgentInfo, AgentListResponse, @@ -1703,6 +1710,25 @@ impl SessionManager { } } + pub(crate) async fn agent_models( + self: &Arc, + agent: AgentId, + ) -> Result { + 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, 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 { + 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 = None; + let mut default_created: Option = 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) -> Result { + let server = self.ensure_codex_server().await?; + let mut models: Vec = Vec::new(); + let mut default_model: Option = None; + let mut seen = HashSet::new(); + let mut cursor: Option = 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 = 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::>() + }) + .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 { + 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 { + 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 { let base_url = self.ensure_opencode_server().await?; let url = format!("{base_url}/session"); @@ -3479,6 +3753,26 @@ pub struct AgentModesResponse { pub modes: Vec, } +#[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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub variants: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_variant: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct AgentModelsResponse { + pub models: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_model: Option, +} + #[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>, + Path(agent): Path, +) -> Result, 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 { } } +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 { + vec!["medium", "high", "xhigh"] + .into_iter() + .map(|value| value.to_string()) + .collect() +} + +fn codex_variants() -> Vec { + vec!["none", "minimal", "low", "medium", "high", "xhigh"] + .into_iter() + .map(|value| value.to_string()) + .collect() +} + +fn parse_opencode_models(value: &Value) -> Option { + 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::>()); + 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 { let mode = agent_mode.unwrap_or("build"); match agent { @@ -4590,6 +5021,7 @@ struct CodexAppServerState { next_id: i64, prompt: String, model: Option, + effort: Option, cwd: Option, approval_policy: Option, sandbox_mode: Option, @@ -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 { + 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 { 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 { + 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) +} diff --git a/server/packages/sandbox-agent/tests/common/http.rs b/server/packages/sandbox-agent/tests/common/http.rs index 8910e62..18a4e6c 100644 --- a/server/packages/sandbox-agent/tests/common/http.rs +++ b/server/packages/sandbox-agent/tests/common/http.rs @@ -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("".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 = 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 = 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") diff --git a/server/packages/sandbox-agent/tests/http/agent_endpoints.rs b/server/packages/sandbox-agent/tests/http/agent_endpoints.rs index f195205..b0fa269 100644 --- a/server/packages/sandbox-agent/tests/http/agent_endpoints.rs +++ b/server/packages/sandbox-agent/tests/http/agent_endpoints.rs @@ -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)); + }); + } } diff --git a/server/packages/sandbox-agent/tests/http/snapshots/agent_endpoints__agent_endpoints_snapshots@agent_models_amp.snap b/server/packages/sandbox-agent/tests/http/snapshots/agent_endpoints__agent_endpoints_snapshots@agent_models_amp.snap new file mode 100644 index 0000000..10c6ff5 --- /dev/null +++ b/server/packages/sandbox-agent/tests/http/snapshots/agent_endpoints__agent_endpoints_snapshots@agent_models_amp.snap @@ -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 diff --git a/server/packages/sandbox-agent/tests/http/snapshots/agent_endpoints__agent_endpoints_snapshots@agent_models_claude.snap b/server/packages/sandbox-agent/tests/http/snapshots/agent_endpoints__agent_endpoints_snapshots@agent_models_claude.snap new file mode 100644 index 0000000..d493d4a --- /dev/null +++ b/server/packages/sandbox-agent/tests/http/snapshots/agent_endpoints__agent_endpoints_snapshots@agent_models_claude.snap @@ -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: "" diff --git a/server/packages/sandbox-agent/tests/http/snapshots/agent_endpoints__agent_endpoints_snapshots@agent_models_codex.snap b/server/packages/sandbox-agent/tests/http/snapshots/agent_endpoints__agent_endpoints_snapshots@agent_models_codex.snap new file mode 100644 index 0000000..977a38c --- /dev/null +++ b/server/packages/sandbox-agent/tests/http/snapshots/agent_endpoints__agent_endpoints_snapshots@agent_models_codex.snap @@ -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 diff --git a/server/packages/sandbox-agent/tests/http/snapshots/agent_endpoints__agent_endpoints_snapshots@agent_models_mock.snap b/server/packages/sandbox-agent/tests/http/snapshots/agent_endpoints__agent_endpoints_snapshots@agent_models_mock.snap new file mode 100644 index 0000000..f5e2b0a --- /dev/null +++ b/server/packages/sandbox-agent/tests/http/snapshots/agent_endpoints__agent_endpoints_snapshots@agent_models_mock.snap @@ -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 diff --git a/server/packages/sandbox-agent/tests/http/snapshots/agent_endpoints__agent_endpoints_snapshots@agent_models_opencode.snap b/server/packages/sandbox-agent/tests/http/snapshots/agent_endpoints__agent_endpoints_snapshots@agent_models_opencode.snap new file mode 100644 index 0000000..d493d4a --- /dev/null +++ b/server/packages/sandbox-agent/tests/http/snapshots/agent_endpoints__agent_endpoints_snapshots@agent_models_opencode.snap @@ -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: "" diff --git a/server/packages/sandbox-agent/tests/opencode-compat/models.test.ts b/server/packages/sandbox-agent/tests/opencode-compat/models.test.ts new file mode 100644 index 0000000..68ef0c0 --- /dev/null +++ b/server/packages/sandbox-agent/tests/opencode-compat/models.test.ts @@ -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"); + }); +});