mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-19 02:01:37 +00:00
feat: model list
This commit is contained in:
parent
6a3345b954
commit
f1005418b1
24 changed files with 1174 additions and 52 deletions
|
|
@ -53,6 +53,7 @@ Universal schema guidance:
|
||||||
- `sandbox-agent api agents list` ↔ `GET /v1/agents`
|
- `sandbox-agent api agents list` ↔ `GET /v1/agents`
|
||||||
- `sandbox-agent api agents install` ↔ `POST /v1/agents/{agent}/install`
|
- `sandbox-agent api agents install` ↔ `POST /v1/agents/{agent}/install`
|
||||||
- `sandbox-agent api agents modes` ↔ `GET /v1/agents/{agent}/modes`
|
- `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 list` ↔ `GET /v1/sessions`
|
||||||
- `sandbox-agent api sessions create` ↔ `POST /v1/sessions/{sessionId}`
|
- `sandbox-agent api sessions create` ↔ `POST /v1/sessions/{sessionId}`
|
||||||
- `sandbox-agent api sessions send-message` ↔ `POST /v1/sessions/{sessionId}/messages`
|
- `sandbox-agent api sessions send-message` ↔ `POST /v1/sessions/{sessionId}/messages`
|
||||||
|
|
|
||||||
11
docs/cli.mdx
11
docs/cli.mdx
|
|
@ -217,6 +217,16 @@ sandbox-agent api agents modes <AGENT>
|
||||||
sandbox-agent api agents modes claude
|
sandbox-agent api agents modes claude
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Get Agent Models
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandbox-agent api agents models <AGENT>
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandbox-agent api agents models claude
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Sessions
|
### Sessions
|
||||||
|
|
@ -377,6 +387,7 @@ sandbox-agent api sessions reply-permission my-session perm1 --reply once
|
||||||
| `api agents list` | `GET /v1/agents` |
|
| `api agents list` | `GET /v1/agents` |
|
||||||
| `api agents install` | `POST /v1/agents/{agent}/install` |
|
| `api agents install` | `POST /v1/agents/{agent}/install` |
|
||||||
| `api agents modes` | `GET /v1/agents/{agent}/modes` |
|
| `api agents modes` | `GET /v1/agents/{agent}/modes` |
|
||||||
|
| `api agents models` | `GET /v1/agents/{agent}/models` |
|
||||||
| `api sessions list` | `GET /v1/sessions` |
|
| `api sessions list` | `GET /v1/sessions` |
|
||||||
| `api sessions create` | `POST /v1/sessions/{sessionId}` |
|
| `api sessions create` | `POST /v1/sessions/{sessionId}` |
|
||||||
| `api sessions send-message` | `POST /v1/sessions/{sessionId}/messages` |
|
| `api sessions send-message` | `POST /v1/sessions/{sessionId}/messages` |
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
"/v1/agents/{agent}/modes": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|
@ -669,6 +710,7 @@
|
||||||
"mcpTools",
|
"mcpTools",
|
||||||
"streamingDeltas",
|
"streamingDeltas",
|
||||||
"itemStarted",
|
"itemStarted",
|
||||||
|
"variants",
|
||||||
"sharedProcess"
|
"sharedProcess"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
@ -726,6 +768,9 @@
|
||||||
},
|
},
|
||||||
"toolResults": {
|
"toolResults": {
|
||||||
"type": "boolean"
|
"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": {
|
"AgentModesResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ This table shows which agent feature coverage appears in the universal event str
|
||||||
| File Changes | - | ✓ | - | - |
|
| File Changes | - | ✓ | - | - |
|
||||||
| MCP Tools | - | ✓ | - | - |
|
| MCP Tools | - | ✓ | - | - |
|
||||||
| Streaming Deltas | ✓ | ✓ | ✓ | - |
|
| 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)
|
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">
|
<Accordion title="Streaming Deltas">
|
||||||
Native streaming of content deltas. When not supported, the daemon emits a single synthetic delta before `item.completed`.
|
Native streaming of content deltas. When not supported, the daemon emits a single synthetic delta before `item.completed`.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
<Accordion title="Variants">
|
||||||
|
Model variants such as reasoning effort or depth. Agents may expose different variant sets per model.
|
||||||
|
</Accordion>
|
||||||
</AccordionGroup>
|
</AccordionGroup>
|
||||||
|
|
||||||
Want support for another agent? [Open an issue](https://github.com/rivet-dev/sandbox-agent/issues/new) to request it.
|
Want support for another agent? [Open an issue](https://github.com/rivet-dev/sandbox-agent/issues/new) to request it.
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import {
|
||||||
SandboxAgentError,
|
SandboxAgentError,
|
||||||
SandboxAgent,
|
SandboxAgent,
|
||||||
type AgentInfo,
|
type AgentInfo,
|
||||||
|
type AgentModelInfo,
|
||||||
type AgentModeInfo,
|
type AgentModeInfo,
|
||||||
type PermissionEventData,
|
type PermissionEventData,
|
||||||
type QuestionEventData,
|
type QuestionEventData,
|
||||||
|
|
@ -89,6 +90,8 @@ export default function App() {
|
||||||
|
|
||||||
const [agents, setAgents] = useState<AgentInfo[]>([]);
|
const [agents, setAgents] = useState<AgentInfo[]>([]);
|
||||||
const [modesByAgent, setModesByAgent] = useState<Record<string, AgentModeInfo[]>>({});
|
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 [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||||
const [agentsLoading, setAgentsLoading] = useState(false);
|
const [agentsLoading, setAgentsLoading] = useState(false);
|
||||||
const [agentsError, setAgentsError] = useState<string | null>(null);
|
const [agentsError, setAgentsError] = useState<string | null>(null);
|
||||||
|
|
@ -96,6 +99,8 @@ export default function App() {
|
||||||
const [sessionsError, setSessionsError] = useState<string | null>(null);
|
const [sessionsError, setSessionsError] = useState<string | null>(null);
|
||||||
const [modesLoadingByAgent, setModesLoadingByAgent] = useState<Record<string, boolean>>({});
|
const [modesLoadingByAgent, setModesLoadingByAgent] = useState<Record<string, boolean>>({});
|
||||||
const [modesErrorByAgent, setModesErrorByAgent] = useState<Record<string, string | null>>({});
|
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 [agentId, setAgentId] = useState("claude");
|
||||||
const [agentMode, setAgentMode] = useState("");
|
const [agentMode, setAgentMode] = useState("");
|
||||||
|
|
@ -252,10 +257,14 @@ export default function App() {
|
||||||
stopTurnStream();
|
stopTurnStream();
|
||||||
setAgents([]);
|
setAgents([]);
|
||||||
setSessions([]);
|
setSessions([]);
|
||||||
|
setModelsByAgent({});
|
||||||
|
setDefaultModelByAgent({});
|
||||||
setAgentsLoading(false);
|
setAgentsLoading(false);
|
||||||
setSessionsLoading(false);
|
setSessionsLoading(false);
|
||||||
setAgentsError(null);
|
setAgentsError(null);
|
||||||
setSessionsError(null);
|
setSessionsError(null);
|
||||||
|
setModelsLoadingByAgent({});
|
||||||
|
setModelsErrorByAgent({});
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshAgents = async () => {
|
const refreshAgents = async () => {
|
||||||
|
|
@ -268,6 +277,7 @@ export default function App() {
|
||||||
for (const agent of agentList) {
|
for (const agent of agentList) {
|
||||||
if (agent.installed) {
|
if (agent.installed) {
|
||||||
loadModes(agent.id);
|
loadModes(agent.id);
|
||||||
|
loadModels(agent.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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 sendMessage = async () => {
|
||||||
const prompt = message.trim();
|
const prompt = message.trim();
|
||||||
if (!prompt || !sessionId || turnStreaming) return;
|
if (!prompt || !sessionId || turnStreaming) return;
|
||||||
|
|
@ -825,6 +858,12 @@ export default function App() {
|
||||||
}
|
}
|
||||||
}, [connected, agentId]);
|
}, [connected, agentId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (connected && agentId && !modelsByAgent[agentId]) {
|
||||||
|
loadModels(agentId);
|
||||||
|
}
|
||||||
|
}, [connected, agentId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const modes = modesByAgent[agentId];
|
const modes = modesByAgent[agentId];
|
||||||
if (modes && modes.length > 0 && !agentMode) {
|
if (modes && modes.length > 0 && !agentMode) {
|
||||||
|
|
@ -836,6 +875,15 @@ export default function App() {
|
||||||
const activeModes = modesByAgent[agentId] ?? [];
|
const activeModes = modesByAgent[agentId] ?? [];
|
||||||
const modesLoading = modesLoadingByAgent[agentId] ?? false;
|
const modesLoading = modesLoadingByAgent[agentId] ?? false;
|
||||||
const modesError = modesErrorByAgent[agentId] ?? null;
|
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> = {
|
const agentDisplayNames: Record<string, string> = {
|
||||||
claude: "Claude Code",
|
claude: "Claude Code",
|
||||||
codex: "Codex",
|
codex: "Codex",
|
||||||
|
|
@ -936,6 +984,13 @@ export default function App() {
|
||||||
permissionMode={permissionMode}
|
permissionMode={permissionMode}
|
||||||
model={model}
|
model={model}
|
||||||
variant={variant}
|
variant={variant}
|
||||||
|
modelOptions={modelOptions}
|
||||||
|
defaultModel={defaultModel}
|
||||||
|
modelsLoading={modelsLoading}
|
||||||
|
modelsError={modelsError}
|
||||||
|
variantOptions={variantOptions}
|
||||||
|
defaultVariant={defaultVariant}
|
||||||
|
supportsVariants={supportsVariants}
|
||||||
streamMode={streamMode}
|
streamMode={streamMode}
|
||||||
activeModes={activeModes}
|
activeModes={activeModes}
|
||||||
currentAgentVersion={currentAgent?.version ?? null}
|
currentAgentVersion={currentAgent?.version ?? null}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
GitBranch,
|
GitBranch,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
Image,
|
Image,
|
||||||
|
Layers,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
PlayCircle,
|
PlayCircle,
|
||||||
|
|
@ -37,7 +38,8 @@ const badges = [
|
||||||
{ key: "fileChanges", label: "File Changes", icon: FileDiff },
|
{ key: "fileChanges", label: "File Changes", icon: FileDiff },
|
||||||
{ key: "mcpTools", label: "MCP", icon: Plug },
|
{ key: "mcpTools", label: "MCP", icon: Plug },
|
||||||
{ key: "streamingDeltas", label: "Deltas", icon: Activity },
|
{ 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;
|
] as const;
|
||||||
|
|
||||||
type BadgeItem = (typeof badges)[number];
|
type BadgeItem = (typeof badges)[number];
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { MessageSquare, PauseCircle, PlayCircle, Plus, Square, Terminal } from "lucide-react";
|
import { MessageSquare, PauseCircle, PlayCircle, Plus, Square, Terminal } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "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 ApprovalsTab from "../debug/ApprovalsTab";
|
||||||
import ChatInput from "./ChatInput";
|
import ChatInput from "./ChatInput";
|
||||||
import ChatMessages from "./ChatMessages";
|
import ChatMessages from "./ChatMessages";
|
||||||
|
|
@ -28,6 +28,13 @@ const ChatPanel = ({
|
||||||
permissionMode,
|
permissionMode,
|
||||||
model,
|
model,
|
||||||
variant,
|
variant,
|
||||||
|
modelOptions,
|
||||||
|
defaultModel,
|
||||||
|
modelsLoading,
|
||||||
|
modelsError,
|
||||||
|
variantOptions,
|
||||||
|
defaultVariant,
|
||||||
|
supportsVariants,
|
||||||
streamMode,
|
streamMode,
|
||||||
activeModes,
|
activeModes,
|
||||||
currentAgentVersion,
|
currentAgentVersion,
|
||||||
|
|
@ -70,6 +77,13 @@ const ChatPanel = ({
|
||||||
permissionMode: string;
|
permissionMode: string;
|
||||||
model: string;
|
model: string;
|
||||||
variant: string;
|
variant: string;
|
||||||
|
modelOptions: AgentModelInfo[];
|
||||||
|
defaultModel: string;
|
||||||
|
modelsLoading: boolean;
|
||||||
|
modelsError: string | null;
|
||||||
|
variantOptions: string[];
|
||||||
|
defaultVariant: string;
|
||||||
|
supportsVariants: boolean;
|
||||||
streamMode: "poll" | "sse" | "turn";
|
streamMode: "poll" | "sse" | "turn";
|
||||||
activeModes: AgentModeInfo[];
|
activeModes: AgentModeInfo[];
|
||||||
currentAgentVersion?: string | null;
|
currentAgentVersion?: string | null;
|
||||||
|
|
@ -277,6 +291,13 @@ const ChatPanel = ({
|
||||||
permissionMode={permissionMode}
|
permissionMode={permissionMode}
|
||||||
model={model}
|
model={model}
|
||||||
variant={variant}
|
variant={variant}
|
||||||
|
modelOptions={modelOptions}
|
||||||
|
defaultModel={defaultModel}
|
||||||
|
modelsLoading={modelsLoading}
|
||||||
|
modelsError={modelsError}
|
||||||
|
variantOptions={variantOptions}
|
||||||
|
defaultVariant={defaultVariant}
|
||||||
|
supportsVariants={supportsVariants}
|
||||||
activeModes={activeModes}
|
activeModes={activeModes}
|
||||||
modesLoading={modesLoading}
|
modesLoading={modesLoading}
|
||||||
modesError={modesError}
|
modesError={modesError}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,17 @@
|
||||||
import type { AgentModeInfo } from "sandbox-agent";
|
import type { AgentModelInfo, AgentModeInfo } from "sandbox-agent";
|
||||||
|
|
||||||
const ChatSetup = ({
|
const ChatSetup = ({
|
||||||
agentMode,
|
agentMode,
|
||||||
permissionMode,
|
permissionMode,
|
||||||
model,
|
model,
|
||||||
variant,
|
variant,
|
||||||
|
modelOptions,
|
||||||
|
defaultModel,
|
||||||
|
modelsLoading,
|
||||||
|
modelsError,
|
||||||
|
variantOptions,
|
||||||
|
defaultVariant,
|
||||||
|
supportsVariants,
|
||||||
activeModes,
|
activeModes,
|
||||||
hasSession,
|
hasSession,
|
||||||
modesLoading,
|
modesLoading,
|
||||||
|
|
@ -18,6 +25,13 @@ const ChatSetup = ({
|
||||||
permissionMode: string;
|
permissionMode: string;
|
||||||
model: string;
|
model: string;
|
||||||
variant: string;
|
variant: string;
|
||||||
|
modelOptions: AgentModelInfo[];
|
||||||
|
defaultModel: string;
|
||||||
|
modelsLoading: boolean;
|
||||||
|
modelsError: string | null;
|
||||||
|
variantOptions: string[];
|
||||||
|
defaultVariant: string;
|
||||||
|
supportsVariants: boolean;
|
||||||
activeModes: AgentModeInfo[];
|
activeModes: AgentModeInfo[];
|
||||||
hasSession: boolean;
|
hasSession: boolean;
|
||||||
modesLoading: boolean;
|
modesLoading: boolean;
|
||||||
|
|
@ -27,6 +41,16 @@ const ChatSetup = ({
|
||||||
onModelChange: (value: string) => void;
|
onModelChange: (value: string) => void;
|
||||||
onVariantChange: (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 (
|
return (
|
||||||
<div className="setup-row">
|
<div className="setup-row">
|
||||||
<div className="setup-field">
|
<div className="setup-field">
|
||||||
|
|
@ -71,6 +95,33 @@ const ChatSetup = ({
|
||||||
|
|
||||||
<div className="setup-field">
|
<div className="setup-field">
|
||||||
<span className="setup-label">Model</span>
|
<span className="setup-label">Model</span>
|
||||||
|
{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
|
<input
|
||||||
className="setup-input"
|
className="setup-input"
|
||||||
value={model}
|
value={model}
|
||||||
|
|
@ -79,18 +130,47 @@ const ChatSetup = ({
|
||||||
title="Model"
|
title="Model"
|
||||||
disabled={!hasSession}
|
disabled={!hasSession}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="setup-field">
|
<div className="setup-field">
|
||||||
<span className="setup-label">Variant</span>
|
<span className="setup-label">Variant</span>
|
||||||
|
{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
|
<input
|
||||||
className="setup-input"
|
className="setup-input"
|
||||||
value={variant}
|
value={variant}
|
||||||
onChange={(e) => onVariantChange(e.target.value)}
|
onChange={(e) => onVariantChange(e.target.value)}
|
||||||
placeholder="Variant"
|
placeholder={supportsVariants ? "Variant" : "Variants unsupported"}
|
||||||
title="Variant"
|
title="Variant"
|
||||||
disabled={!hasSession}
|
disabled={!hasSession || !supportsVariants}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ export type FeatureCoverageView = AgentCapabilities & {
|
||||||
mcpTools?: boolean;
|
mcpTools?: boolean;
|
||||||
streamingDeltas?: boolean;
|
streamingDeltas?: boolean;
|
||||||
itemStarted?: boolean;
|
itemStarted?: boolean;
|
||||||
|
variants?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const emptyFeatureCoverage: FeatureCoverageView = {
|
export const emptyFeatureCoverage: FeatureCoverageView = {
|
||||||
|
|
@ -34,5 +35,6 @@ export const emptyFeatureCoverage: FeatureCoverageView = {
|
||||||
mcpTools: false,
|
mcpTools: false,
|
||||||
streamingDeltas: false,
|
streamingDeltas: false,
|
||||||
itemStarted: false,
|
itemStarted: false,
|
||||||
|
variants: false,
|
||||||
sharedProcess: false
|
sharedProcess: false
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type { SandboxAgentSpawnHandle, SandboxAgentSpawnOptions } from "./spawn.
|
||||||
import type {
|
import type {
|
||||||
AgentInstallRequest,
|
AgentInstallRequest,
|
||||||
AgentListResponse,
|
AgentListResponse,
|
||||||
|
AgentModelsResponse,
|
||||||
AgentModesResponse,
|
AgentModesResponse,
|
||||||
CreateSessionRequest,
|
CreateSessionRequest,
|
||||||
CreateSessionResponse,
|
CreateSessionResponse,
|
||||||
|
|
@ -113,6 +114,10 @@ export class SandboxAgent {
|
||||||
return this.requestJson("GET", `${API_PREFIX}/agents/${encodeURIComponent(agent)}/modes`);
|
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> {
|
async createSession(sessionId: string, request: CreateSessionRequest): Promise<CreateSessionResponse> {
|
||||||
return this.requestJson("POST", `${API_PREFIX}/sessions/${encodeURIComponent(sessionId)}`, {
|
return this.requestJson("POST", `${API_PREFIX}/sessions/${encodeURIComponent(sessionId)}`, {
|
||||||
body: request,
|
body: request,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ export interface paths {
|
||||||
"/v1/agents/{agent}/install": {
|
"/v1/agents/{agent}/install": {
|
||||||
post: operations["install_agent"];
|
post: operations["install_agent"];
|
||||||
};
|
};
|
||||||
|
"/v1/agents/{agent}/models": {
|
||||||
|
get: operations["get_agent_models"];
|
||||||
|
};
|
||||||
"/v1/agents/{agent}/modes": {
|
"/v1/agents/{agent}/modes": {
|
||||||
get: operations["get_agent_modes"];
|
get: operations["get_agent_modes"];
|
||||||
};
|
};
|
||||||
|
|
@ -73,6 +76,7 @@ export interface components {
|
||||||
textMessages: boolean;
|
textMessages: boolean;
|
||||||
toolCalls: boolean;
|
toolCalls: boolean;
|
||||||
toolResults: boolean;
|
toolResults: boolean;
|
||||||
|
variants: boolean;
|
||||||
};
|
};
|
||||||
AgentError: {
|
AgentError: {
|
||||||
agent?: string | null;
|
agent?: string | null;
|
||||||
|
|
@ -100,6 +104,16 @@ export interface components {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
AgentModelInfo: {
|
||||||
|
defaultVariant?: string | null;
|
||||||
|
id: string;
|
||||||
|
name?: string | null;
|
||||||
|
variants?: string[] | null;
|
||||||
|
};
|
||||||
|
AgentModelsResponse: {
|
||||||
|
defaultModel?: string | null;
|
||||||
|
models: components["schemas"]["AgentModelInfo"][];
|
||||||
|
};
|
||||||
AgentModesResponse: {
|
AgentModesResponse: {
|
||||||
modes: components["schemas"]["AgentModeInfo"][];
|
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: {
|
get_agent_modes: {
|
||||||
parameters: {
|
parameters: {
|
||||||
path: {
|
path: {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ export type {
|
||||||
AgentInfo,
|
AgentInfo,
|
||||||
AgentInstallRequest,
|
AgentInstallRequest,
|
||||||
AgentListResponse,
|
AgentListResponse,
|
||||||
|
AgentModelInfo,
|
||||||
|
AgentModelsResponse,
|
||||||
AgentModeInfo,
|
AgentModeInfo,
|
||||||
AgentModesResponse,
|
AgentModesResponse,
|
||||||
AgentUnparsedData,
|
AgentUnparsedData,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ export type AgentCapabilities = S["AgentCapabilities"];
|
||||||
export type AgentInfo = S["AgentInfo"];
|
export type AgentInfo = S["AgentInfo"];
|
||||||
export type AgentInstallRequest = S["AgentInstallRequest"];
|
export type AgentInstallRequest = S["AgentInstallRequest"];
|
||||||
export type AgentListResponse = S["AgentListResponse"];
|
export type AgentListResponse = S["AgentListResponse"];
|
||||||
|
export type AgentModelInfo = S["AgentModelInfo"];
|
||||||
|
export type AgentModelsResponse = S["AgentModelsResponse"];
|
||||||
export type AgentModeInfo = S["AgentModeInfo"];
|
export type AgentModeInfo = S["AgentModeInfo"];
|
||||||
export type AgentModesResponse = S["AgentModesResponse"];
|
export type AgentModesResponse = S["AgentModesResponse"];
|
||||||
export type AgentUnparsedData = S["AgentUnparsedData"];
|
export type AgentUnparsedData = S["AgentUnparsedData"];
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,8 @@ use crate::router::{
|
||||||
PermissionReply, PermissionReplyRequest, QuestionReplyRequest,
|
PermissionReply, PermissionReplyRequest, QuestionReplyRequest,
|
||||||
};
|
};
|
||||||
use crate::router::{
|
use crate::router::{
|
||||||
AgentListResponse, AgentModesResponse, CreateSessionResponse, EventsResponse,
|
AgentListResponse, AgentModelsResponse, AgentModesResponse, CreateSessionResponse,
|
||||||
SessionListResponse,
|
EventsResponse, SessionListResponse,
|
||||||
};
|
};
|
||||||
use crate::server_logs::ServerLogs;
|
use crate::server_logs::ServerLogs;
|
||||||
use crate::telemetry;
|
use crate::telemetry;
|
||||||
|
|
@ -228,6 +228,8 @@ pub enum AgentsCommand {
|
||||||
Install(ApiInstallAgentArgs),
|
Install(ApiInstallAgentArgs),
|
||||||
/// Show available modes for an agent.
|
/// Show available modes for an agent.
|
||||||
Modes(AgentModesArgs),
|
Modes(AgentModesArgs),
|
||||||
|
/// Show available models for an agent.
|
||||||
|
Models(AgentModelsArgs),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
|
|
@ -294,6 +296,13 @@ pub struct AgentModesArgs {
|
||||||
client: ClientArgs,
|
client: ClientArgs,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
pub struct AgentModelsArgs {
|
||||||
|
agent: String,
|
||||||
|
#[command(flatten)]
|
||||||
|
client: ClientArgs,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Args, Debug)]
|
#[derive(Args, Debug)]
|
||||||
pub struct CreateSessionArgs {
|
pub struct CreateSessionArgs {
|
||||||
session_id: String,
|
session_id: String,
|
||||||
|
|
@ -650,6 +659,12 @@ fn run_agents(command: &AgentsCommand, cli: &CliConfig) -> Result<(), CliError>
|
||||||
let response = ctx.get(&path)?;
|
let response = ctx.get(&path)?;
|
||||||
print_json_response::<AgentModesResponse>(response)
|
print_json_response::<AgentModesResponse>(response)
|
||||||
}
|
}
|
||||||
|
AgentsCommand::Models(args) => {
|
||||||
|
let ctx = ClientContext::new(cli, &args.client)?;
|
||||||
|
let path = format!("{API_PREFIX}/agents/{}/models", args.agent);
|
||||||
|
let response = ctx.get(&path)?;
|
||||||
|
print_json_response::<AgentModelsResponse>(response)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ use tokio::sync::{broadcast, Mutex};
|
||||||
use tokio::time::interval;
|
use tokio::time::interval;
|
||||||
use utoipa::{IntoParams, OpenApi, ToSchema};
|
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_agent_management::agents::AgentId;
|
||||||
use sandbox_agent_error::SandboxError;
|
use sandbox_agent_error::SandboxError;
|
||||||
use sandbox_agent_universal_agent_schema::{
|
use sandbox_agent_universal_agent_schema::{
|
||||||
|
|
@ -218,6 +218,19 @@ struct OpenCodeSessionRuntime {
|
||||||
tool_args_by_call: HashMap<String, String>,
|
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 {
|
pub struct OpenCodeState {
|
||||||
config: OpenCodeCompatConfig,
|
config: OpenCodeCompatConfig,
|
||||||
default_project_id: String,
|
default_project_id: String,
|
||||||
|
|
@ -229,6 +242,7 @@ pub struct OpenCodeState {
|
||||||
session_runtime: Mutex<HashMap<String, OpenCodeSessionRuntime>>,
|
session_runtime: Mutex<HashMap<String, OpenCodeSessionRuntime>>,
|
||||||
session_streams: Mutex<HashMap<String, bool>>,
|
session_streams: Mutex<HashMap<String, bool>>,
|
||||||
event_broadcaster: broadcast::Sender<Value>,
|
event_broadcaster: broadcast::Sender<Value>,
|
||||||
|
model_cache: Mutex<Option<OpenCodeModelCache>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OpenCodeState {
|
impl OpenCodeState {
|
||||||
|
|
@ -246,6 +260,7 @@ impl OpenCodeState {
|
||||||
session_runtime: Mutex::new(HashMap::new()),
|
session_runtime: Mutex::new(HashMap::new()),
|
||||||
session_streams: Mutex::new(HashMap::new()),
|
session_streams: Mutex::new(HashMap::new()),
|
||||||
event_broadcaster,
|
event_broadcaster,
|
||||||
|
model_cache: Mutex::new(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -591,12 +606,88 @@ fn default_agent_mode() -> &'static str {
|
||||||
OPENCODE_DEFAULT_AGENT_MODE
|
OPENCODE_DEFAULT_AGENT_MODE
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_agent_from_model(provider_id: &str, model_id: &str) -> Option<AgentId> {
|
async fn opencode_model_cache(state: &OpenCodeAppState) -> OpenCodeModelCache {
|
||||||
if provider_id == OPENCODE_PROVIDER_ID {
|
{
|
||||||
AgentId::parse(model_id)
|
let cache = state.opencode.model_cache.lock().await;
|
||||||
} else {
|
if let Some(cache) = cache.as_ref() {
|
||||||
None
|
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 {
|
fn normalize_agent_mode(agent: Option<String>) -> String {
|
||||||
|
|
@ -611,19 +702,22 @@ async fn resolve_session_agent(
|
||||||
requested_provider: Option<&str>,
|
requested_provider: Option<&str>,
|
||||||
requested_model: Option<&str>,
|
requested_model: Option<&str>,
|
||||||
) -> (String, String, String) {
|
) -> (String, String, String) {
|
||||||
|
let cache = opencode_model_cache(state).await;
|
||||||
|
let default_model_id = cache.default_model.clone();
|
||||||
let mut provider_id = requested_provider
|
let mut provider_id = requested_provider
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.unwrap_or(OPENCODE_PROVIDER_ID)
|
.unwrap_or(OPENCODE_PROVIDER_ID)
|
||||||
.to_string();
|
.to_string();
|
||||||
let mut model_id = requested_model
|
let mut model_id = requested_model
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.unwrap_or(OPENCODE_DEFAULT_MODEL_ID)
|
.unwrap_or(default_model_id.as_str())
|
||||||
.to_string();
|
.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() {
|
if resolved_agent.is_none() {
|
||||||
provider_id = OPENCODE_PROVIDER_ID.to_string();
|
provider_id = OPENCODE_PROVIDER_ID.to_string();
|
||||||
model_id = OPENCODE_DEFAULT_MODEL_ID.to_string();
|
model_id = default_model_id.clone();
|
||||||
resolved_agent = Some(default_agent_id());
|
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;
|
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 {
|
fn agent_display_name(agent: AgentId) -> &'static str {
|
||||||
match agent {
|
match agent {
|
||||||
AgentId::Claude => "Claude",
|
AgentId::Claude => "Claude Code",
|
||||||
AgentId::Codex => "Codex",
|
AgentId::Codex => "Codex",
|
||||||
AgentId::Opencode => "OpenCode",
|
AgentId::Opencode => "OpenCode",
|
||||||
AgentId::Amp => "Amp",
|
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!({
|
json!({
|
||||||
"id": agent.as_str(),
|
"id": model.id,
|
||||||
"providerID": OPENCODE_PROVIDER_ID,
|
"providerID": OPENCODE_PROVIDER_ID,
|
||||||
"api": {
|
"api": {
|
||||||
"id": "sandbox-agent",
|
"id": "sandbox-agent",
|
||||||
"url": "http://localhost",
|
"url": "http://localhost",
|
||||||
"npm": "@sandbox-agent/sdk"
|
"npm": "@sandbox-agent/sdk"
|
||||||
},
|
},
|
||||||
"name": agent_display_name(agent),
|
"name": model_name,
|
||||||
"family": "sandbox-agent",
|
"family": agent_display_name(agent),
|
||||||
"capabilities": {
|
"capabilities": {
|
||||||
"temperature": true,
|
"temperature": true,
|
||||||
"reasoning": true,
|
"reasoning": true,
|
||||||
|
|
@ -707,14 +803,17 @@ fn model_config_entry(agent: AgentId) -> Value {
|
||||||
"options": {},
|
"options": {},
|
||||||
"headers": {},
|
"headers": {},
|
||||||
"release_date": "2024-01-01",
|
"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!({
|
json!({
|
||||||
"id": agent.as_str(),
|
"id": model.id,
|
||||||
"name": agent_display_name(agent),
|
"name": model_name,
|
||||||
|
"family": agent_display_name(agent),
|
||||||
"release_date": "2024-01-01",
|
"release_date": "2024-01-01",
|
||||||
"attachment": false,
|
"attachment": false,
|
||||||
"reasoning": true,
|
"reasoning": true,
|
||||||
|
|
@ -724,10 +823,22 @@ fn model_summary_entry(agent: AgentId) -> Value {
|
||||||
"limit": {
|
"limit": {
|
||||||
"context": 128000,
|
"context": 128000,
|
||||||
"output": 4096
|
"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>) {
|
fn bad_request(message: &str) -> (StatusCode, Json<Value>) {
|
||||||
(
|
(
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
|
|
@ -2351,10 +2462,16 @@ async fn oc_config_patch(Json(body): Json<Value>) -> impl IntoResponse {
|
||||||
responses((status = 200)),
|
responses((status = 200)),
|
||||||
tag = "opencode"
|
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();
|
let mut models = serde_json::Map::new();
|
||||||
for agent in available_agent_ids() {
|
for entry in &cache.entries {
|
||||||
models.insert(agent.as_str().to_string(), model_config_entry(agent));
|
models.insert(
|
||||||
|
entry.model.id.clone(),
|
||||||
|
model_config_entry(entry.agent, &entry.model),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let providers = json!({
|
let providers = json!({
|
||||||
"providers": [
|
"providers": [
|
||||||
|
|
@ -2369,7 +2486,7 @@ async fn oc_config_providers() -> impl IntoResponse {
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"default": {
|
"default": {
|
||||||
OPENCODE_PROVIDER_ID: OPENCODE_DEFAULT_MODEL_ID
|
OPENCODE_PROVIDER_ID: cache.default_model
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
(StatusCode::OK, Json(providers))
|
(StatusCode::OK, Json(providers))
|
||||||
|
|
@ -3648,10 +3765,16 @@ async fn oc_question_reject(
|
||||||
responses((status = 200)),
|
responses((status = 200)),
|
||||||
tag = "opencode"
|
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();
|
let mut models = serde_json::Map::new();
|
||||||
for agent in available_agent_ids() {
|
for entry in &cache.entries {
|
||||||
models.insert(agent.as_str().to_string(), model_summary_entry(agent));
|
models.insert(
|
||||||
|
entry.model.id.clone(),
|
||||||
|
model_summary_entry(entry.agent, &entry.model),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let providers = json!({
|
let providers = json!({
|
||||||
"all": [
|
"all": [
|
||||||
|
|
@ -3663,7 +3786,7 @@ async fn oc_provider_list() -> impl IntoResponse {
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"default": {
|
"default": {
|
||||||
OPENCODE_PROVIDER_ID: OPENCODE_DEFAULT_MODEL_ID
|
OPENCODE_PROVIDER_ID: cache.default_model
|
||||||
},
|
},
|
||||||
"connected": [OPENCODE_PROVIDER_ID]
|
"connected": [OPENCODE_PROVIDER_ID]
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -46,11 +46,14 @@ use sandbox_agent_agent_management::agents::{
|
||||||
AgentError as ManagerError, AgentId, AgentManager, InstallOptions, SpawnOptions, StreamingSpawn,
|
AgentError as ManagerError, AgentId, AgentManager, InstallOptions, SpawnOptions, StreamingSpawn,
|
||||||
};
|
};
|
||||||
use sandbox_agent_agent_management::credentials::{
|
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;
|
const MOCK_EVENT_DELAY_MS: u64 = 200;
|
||||||
static USER_MESSAGE_COUNTER: AtomicU64 = AtomicU64::new(1);
|
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)]
|
#[derive(Debug)]
|
||||||
pub struct AppState {
|
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", get(list_agents))
|
||||||
.route("/agents/:agent/install", post(install_agent))
|
.route("/agents/:agent/install", post(install_agent))
|
||||||
.route("/agents/:agent/modes", get(get_agent_modes))
|
.route("/agents/:agent/modes", get(get_agent_modes))
|
||||||
|
.route("/agents/:agent/models", get(get_agent_models))
|
||||||
.route("/sessions", get(list_sessions))
|
.route("/sessions", get(list_sessions))
|
||||||
.route("/sessions/:session_id", post(create_session))
|
.route("/sessions/:session_id", post(create_session))
|
||||||
.route("/sessions/:session_id/messages", post(post_message))
|
.route("/sessions/:session_id/messages", post(post_message))
|
||||||
|
|
@ -218,6 +222,7 @@ pub async fn shutdown_servers(state: &Arc<AppState>) {
|
||||||
get_health,
|
get_health,
|
||||||
install_agent,
|
install_agent,
|
||||||
get_agent_modes,
|
get_agent_modes,
|
||||||
|
get_agent_models,
|
||||||
list_agents,
|
list_agents,
|
||||||
list_sessions,
|
list_sessions,
|
||||||
create_session,
|
create_session,
|
||||||
|
|
@ -235,6 +240,8 @@ pub async fn shutdown_servers(state: &Arc<AppState>) {
|
||||||
AgentInstallRequest,
|
AgentInstallRequest,
|
||||||
AgentModeInfo,
|
AgentModeInfo,
|
||||||
AgentModesResponse,
|
AgentModesResponse,
|
||||||
|
AgentModelInfo,
|
||||||
|
AgentModelsResponse,
|
||||||
AgentCapabilities,
|
AgentCapabilities,
|
||||||
AgentInfo,
|
AgentInfo,
|
||||||
AgentListResponse,
|
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(
|
pub(crate) async fn send_message(
|
||||||
self: &Arc<Self>,
|
self: &Arc<Self>,
|
||||||
session_id: String,
|
session_id: String,
|
||||||
|
|
@ -3155,7 +3181,7 @@ impl SessionManager {
|
||||||
approval_policy: codex_approval_policy(Some(&session.permission_mode)),
|
approval_policy: codex_approval_policy(Some(&session.permission_mode)),
|
||||||
collaboration_mode: None,
|
collaboration_mode: None,
|
||||||
cwd: None,
|
cwd: None,
|
||||||
effort: None,
|
effort: codex_effort_from_variant(session.variant.as_deref()),
|
||||||
input: vec![codex_schema::UserInput::Text {
|
input: vec![codex_schema::UserInput::Text {
|
||||||
text: prompt_text,
|
text: prompt_text,
|
||||||
text_elements: Vec::new(),
|
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> {
|
async fn create_opencode_session(&self) -> Result<String, SandboxError> {
|
||||||
let base_url = self.ensure_opencode_server().await?;
|
let base_url = self.ensure_opencode_server().await?;
|
||||||
let url = format!("{base_url}/session");
|
let url = format!("{base_url}/session");
|
||||||
|
|
@ -3479,6 +3753,26 @@ pub struct AgentModesResponse {
|
||||||
pub modes: Vec<AgentModeInfo>,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct AgentCapabilities {
|
pub struct AgentCapabilities {
|
||||||
|
|
@ -3500,6 +3794,7 @@ pub struct AgentCapabilities {
|
||||||
pub mcp_tools: bool,
|
pub mcp_tools: bool,
|
||||||
pub streaming_deltas: bool,
|
pub streaming_deltas: bool,
|
||||||
pub item_started: bool,
|
pub item_started: bool,
|
||||||
|
pub variants: bool,
|
||||||
/// Whether this agent uses a shared long-running server process (vs per-turn subprocess)
|
/// Whether this agent uses a shared long-running server process (vs per-turn subprocess)
|
||||||
pub shared_process: bool,
|
pub shared_process: bool,
|
||||||
}
|
}
|
||||||
|
|
@ -3733,6 +4028,25 @@ async fn get_agent_modes(
|
||||||
Ok(Json(AgentModesResponse { 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 = "\
|
const SERVER_INFO: &str = "\
|
||||||
This is a Sandbox Agent server. Available endpoints:\n\
|
This is a Sandbox Agent server. Available endpoints:\n\
|
||||||
- GET / - Server info\n\
|
- GET / - Server info\n\
|
||||||
|
|
@ -4133,6 +4447,7 @@ fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities {
|
||||||
mcp_tools: false,
|
mcp_tools: false,
|
||||||
streaming_deltas: true,
|
streaming_deltas: true,
|
||||||
item_started: false,
|
item_started: false,
|
||||||
|
variants: false,
|
||||||
shared_process: false, // per-turn subprocess with --resume
|
shared_process: false, // per-turn subprocess with --resume
|
||||||
},
|
},
|
||||||
AgentId::Codex => AgentCapabilities {
|
AgentId::Codex => AgentCapabilities {
|
||||||
|
|
@ -4153,6 +4468,7 @@ fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities {
|
||||||
mcp_tools: true,
|
mcp_tools: true,
|
||||||
streaming_deltas: true,
|
streaming_deltas: true,
|
||||||
item_started: true,
|
item_started: true,
|
||||||
|
variants: true,
|
||||||
shared_process: true, // shared app-server via JSON-RPC
|
shared_process: true, // shared app-server via JSON-RPC
|
||||||
},
|
},
|
||||||
AgentId::Opencode => AgentCapabilities {
|
AgentId::Opencode => AgentCapabilities {
|
||||||
|
|
@ -4173,6 +4489,7 @@ fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities {
|
||||||
mcp_tools: false,
|
mcp_tools: false,
|
||||||
streaming_deltas: true,
|
streaming_deltas: true,
|
||||||
item_started: true,
|
item_started: true,
|
||||||
|
variants: true,
|
||||||
shared_process: true, // shared HTTP server
|
shared_process: true, // shared HTTP server
|
||||||
},
|
},
|
||||||
AgentId::Amp => AgentCapabilities {
|
AgentId::Amp => AgentCapabilities {
|
||||||
|
|
@ -4193,6 +4510,7 @@ fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities {
|
||||||
mcp_tools: false,
|
mcp_tools: false,
|
||||||
streaming_deltas: false,
|
streaming_deltas: false,
|
||||||
item_started: false,
|
item_started: false,
|
||||||
|
variants: true,
|
||||||
shared_process: false, // per-turn subprocess with --continue
|
shared_process: false, // per-turn subprocess with --continue
|
||||||
},
|
},
|
||||||
AgentId::Mock => AgentCapabilities {
|
AgentId::Mock => AgentCapabilities {
|
||||||
|
|
@ -4213,6 +4531,7 @@ fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities {
|
||||||
mcp_tools: true,
|
mcp_tools: true,
|
||||||
streaming_deltas: true,
|
streaming_deltas: true,
|
||||||
item_started: true,
|
item_started: true,
|
||||||
|
variants: false,
|
||||||
shared_process: false, // in-memory mock (no subprocess)
|
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> {
|
fn normalize_agent_mode(agent: AgentId, agent_mode: Option<&str>) -> Result<String, SandboxError> {
|
||||||
let mode = agent_mode.unwrap_or("build");
|
let mode = agent_mode.unwrap_or("build");
|
||||||
match agent {
|
match agent {
|
||||||
|
|
@ -4590,6 +5021,7 @@ struct CodexAppServerState {
|
||||||
next_id: i64,
|
next_id: i64,
|
||||||
prompt: String,
|
prompt: String,
|
||||||
model: Option<String>,
|
model: Option<String>,
|
||||||
|
effort: Option<codex_schema::ReasoningEffort>,
|
||||||
cwd: Option<String>,
|
cwd: Option<String>,
|
||||||
approval_policy: Option<codex_schema::AskForApproval>,
|
approval_policy: Option<codex_schema::AskForApproval>,
|
||||||
sandbox_mode: Option<codex_schema::SandboxMode>,
|
sandbox_mode: Option<codex_schema::SandboxMode>,
|
||||||
|
|
@ -4614,6 +5046,7 @@ impl CodexAppServerState {
|
||||||
next_id: 1,
|
next_id: 1,
|
||||||
prompt,
|
prompt,
|
||||||
model: options.model.clone(),
|
model: options.model.clone(),
|
||||||
|
effort: codex_effort_from_variant(options.variant.as_deref()),
|
||||||
cwd,
|
cwd,
|
||||||
approval_policy: codex_approval_policy(options.permission_mode.as_deref()),
|
approval_policy: codex_approval_policy(options.permission_mode.as_deref()),
|
||||||
sandbox_mode: codex_sandbox_mode(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,
|
approval_policy: self.approval_policy,
|
||||||
collaboration_mode: None,
|
collaboration_mode: None,
|
||||||
cwd: self.cwd.clone(),
|
cwd: self.cwd.clone(),
|
||||||
effort: None,
|
effort: self.effort.clone(),
|
||||||
input: vec![codex_schema::UserInput::Text {
|
input: vec![codex_schema::UserInput::Text {
|
||||||
text: self.prompt.clone(),
|
text: self.prompt.clone(),
|
||||||
text_elements: Vec::new(),
|
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> {
|
fn codex_approval_policy(mode: Option<&str>) -> Option<codex_schema::AskForApproval> {
|
||||||
match mode {
|
match mode {
|
||||||
Some("plan") => Some(codex_schema::AskForApproval::Untrusted),
|
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);
|
headers.insert(axum::http::header::AUTHORIZATION, header);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_anthropic_headers(
|
||||||
|
credentials: &ProviderCredentials,
|
||||||
|
) -> Result<reqwest::header::HeaderMap, SandboxError> {
|
||||||
|
let mut headers = reqwest::header::HeaderMap::new();
|
||||||
|
match credentials.auth_type {
|
||||||
|
AuthType::ApiKey => {
|
||||||
|
let value =
|
||||||
|
reqwest::header::HeaderValue::from_str(&credentials.api_key).map_err(|_| {
|
||||||
|
SandboxError::StreamError {
|
||||||
|
message: "invalid anthropic api key header".to_string(),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
headers.insert("x-api-key", value);
|
||||||
|
}
|
||||||
|
AuthType::Oauth => {
|
||||||
|
let value = format!("Bearer {}", credentials.api_key);
|
||||||
|
let header =
|
||||||
|
reqwest::header::HeaderValue::from_str(&value).map_err(|_| {
|
||||||
|
SandboxError::StreamError {
|
||||||
|
message: "invalid anthropic oauth header".to_string(),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
headers.insert(reqwest::header::AUTHORIZATION, header);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
headers.insert(
|
||||||
|
"anthropic-version",
|
||||||
|
reqwest::header::HeaderValue::from_static(ANTHROPIC_VERSION),
|
||||||
|
);
|
||||||
|
Ok(headers)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -736,6 +736,81 @@ fn normalize_agent_modes(value: &Value) -> Value {
|
||||||
json!({ "modes": normalized })
|
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 {
|
fn normalize_sessions(value: &Value) -> Value {
|
||||||
let sessions = value
|
let sessions = value
|
||||||
.get("sessions")
|
.get("sessions")
|
||||||
|
|
|
||||||
|
|
@ -162,4 +162,27 @@ async fn agent_endpoints_snapshots() {
|
||||||
insta::assert_yaml_snapshot!(normalize_agent_modes(&modes));
|
insta::assert_yaml_snapshot!(normalize_agent_modes(&modes));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for config in &configs {
|
||||||
|
let _guard = apply_credentials(&config.credentials);
|
||||||
|
let (status, models) = send_json(
|
||||||
|
&app.app,
|
||||||
|
Method::GET,
|
||||||
|
&format!("/v1/agents/{}/models", config.agent.as_str()),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "agent models");
|
||||||
|
let model_count = models
|
||||||
|
.get("models")
|
||||||
|
.and_then(|value| value.as_array())
|
||||||
|
.map(|models| models.len())
|
||||||
|
.unwrap_or_default();
|
||||||
|
assert!(model_count > 0, "agent models should not be empty");
|
||||||
|
insta::with_settings!({
|
||||||
|
snapshot_suffix => snapshot_name("agent_models", Some(config.agent)),
|
||||||
|
}, {
|
||||||
|
insta::assert_yaml_snapshot!(normalize_agent_models(&models, config.agent));
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
---
|
||||||
|
source: server/packages/sandbox-agent/tests/http_sse_snapshots.rs
|
||||||
|
expression: normalize_agent_models(&models, config.agent)
|
||||||
|
---
|
||||||
|
nonEmpty: true
|
||||||
|
hasDefault: true
|
||||||
|
defaultInList: true
|
||||||
|
hasVariants: true
|
||||||
|
modelCount: 4
|
||||||
|
ids:
|
||||||
|
- deep
|
||||||
|
- free
|
||||||
|
- rush
|
||||||
|
- smart
|
||||||
|
defaultModel: smart
|
||||||
|
variants:
|
||||||
|
- high
|
||||||
|
- medium
|
||||||
|
- xhigh
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
---
|
||||||
|
source: server/packages/sandbox-agent/tests/http_sse_snapshots.rs
|
||||||
|
expression: normalize_agent_models(&models, config.agent)
|
||||||
|
---
|
||||||
|
nonEmpty: true
|
||||||
|
hasDefault: true
|
||||||
|
defaultInList: true
|
||||||
|
hasVariants: "<redacted>"
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
---
|
||||||
|
source: server/packages/sandbox-agent/tests/http_sse_snapshots.rs
|
||||||
|
expression: normalize_agent_models(&models, config.agent)
|
||||||
|
---
|
||||||
|
nonEmpty: true
|
||||||
|
hasDefault: true
|
||||||
|
defaultInList: true
|
||||||
|
hasVariants: true
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
---
|
||||||
|
source: server/packages/sandbox-agent/tests/http_sse_snapshots.rs
|
||||||
|
expression: normalize_agent_models(&models, config.agent)
|
||||||
|
---
|
||||||
|
nonEmpty: true
|
||||||
|
hasDefault: true
|
||||||
|
defaultInList: true
|
||||||
|
hasVariants: false
|
||||||
|
modelCount: 1
|
||||||
|
ids:
|
||||||
|
- mock
|
||||||
|
defaultModel: mock
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
---
|
||||||
|
source: server/packages/sandbox-agent/tests/http_sse_snapshots.rs
|
||||||
|
expression: normalize_agent_models(&models, config.agent)
|
||||||
|
---
|
||||||
|
nonEmpty: true
|
||||||
|
hasDefault: true
|
||||||
|
defaultInList: true
|
||||||
|
hasVariants: "<redacted>"
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
/**
|
||||||
|
* Tests for OpenCode-compatible provider/model listing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeAll, afterEach, beforeEach } from "vitest";
|
||||||
|
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk";
|
||||||
|
import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn";
|
||||||
|
|
||||||
|
describe("OpenCode-compatible Model API", () => {
|
||||||
|
let handle: SandboxAgentHandle;
|
||||||
|
let client: OpencodeClient;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await buildSandboxAgent();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
handle = await spawnSandboxAgent({ opencodeCompat: true });
|
||||||
|
client = createOpencodeClient({
|
||||||
|
baseUrl: `${handle.baseUrl}/opencode`,
|
||||||
|
headers: { Authorization: `Bearer ${handle.token}` },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await handle?.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should list models grouped by agent with real model IDs", async () => {
|
||||||
|
const response = await client.provider.list();
|
||||||
|
const provider = response.data?.all?.find((entry) => entry.id === "sandbox-agent");
|
||||||
|
expect(provider).toBeDefined();
|
||||||
|
|
||||||
|
const models = provider?.models ?? {};
|
||||||
|
const modelIds = Object.keys(models);
|
||||||
|
expect(modelIds.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
expect(models["mock"]).toBeDefined();
|
||||||
|
expect(models["mock"].id).toBe("mock");
|
||||||
|
expect(models["mock"].family).toBe("Mock");
|
||||||
|
|
||||||
|
expect(models["smart"]).toBeDefined();
|
||||||
|
expect(models["smart"].id).toBe("smart");
|
||||||
|
expect(models["smart"].family).toBe("Amp");
|
||||||
|
|
||||||
|
expect(models["amp"]).toBeUndefined();
|
||||||
|
expect(response.data?.default?.["sandbox-agent"]).toBe("mock");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue