diff --git a/.github/media/gigacode-header.jpeg b/.github/media/gigacode-header.jpeg
new file mode 100644
index 0000000..4708249
Binary files /dev/null and b/.github/media/gigacode-header.jpeg differ
diff --git a/CLAUDE.md b/CLAUDE.md
index 5eec975..fae0758 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -83,7 +83,7 @@ SANDBOX_AGENT_SKIP_INSPECTOR=1 pnpm --filter @sandbox-agent/opencode-compat-test
## Naming
-- The product name is "GigaCode" (capital G, capital C). The CLI binary/package is `gigacode` (lowercase).
+- The product name is "Gigacode" (capital G, lowercase c). The CLI binary/package is `gigacode` (lowercase).
## Git Commits
diff --git a/Cargo.toml b/Cargo.toml
index 74906c3..b6812cc 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[workspace]
resolver = "2"
-members = ["server/packages/*"]
+members = ["server/packages/*", "gigacode"]
[workspace.package]
version = "0.1.6"
diff --git a/README.md b/README.md
index dbaf6d6..aac18f7 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,10 @@
Documentation — API Reference — Discord
+
+ Experimental: Gigacode — use OpenCode's TUI with any coding agent.
+
+
## Why Sandbox Agent?
Running coding agents remotely is hard. Existing SDKs assume local execution, SSH breaks TTY handling and streaming, and every agent has a different API. Building from scratch means reimplementing everything for each coding agent.
diff --git a/docs/gigacode.mdx b/docs/gigacode.mdx
index 8cbf7b3..ccc9e39 100644
--- a/docs/gigacode.mdx
+++ b/docs/gigacode.mdx
@@ -1,6 +1,6 @@
---
-title: GigaCode
-url: "https://github.com/rivet-dev/sandbox-agent/tree/main/server/packages/gigacode"
+title: Gigacode
+url: "https://github.com/rivet-dev/sandbox-agent/tree/main/gigacode"
---
diff --git a/docs/opencode-compatibility.mdx b/docs/opencode-compatibility.mdx
index be7da6f..004f048 100644
--- a/docs/opencode-compatibility.mdx
+++ b/docs/opencode-compatibility.mdx
@@ -112,6 +112,7 @@ for await (const event of events.stream) {
- **Authentication**: If sandbox-agent is started with `--token`, include `Authorization: Bearer ` header or use `--password` flag with CLI
- **CORS**: When using the web UI from a different origin, configure `--cors-allow-origin`
- **Provider Selection**: Use the provider/model selector in the UI to choose which backing agent to use (claude, codex, opencode, amp)
+- **Models & Variants**: Providers are grouped by backing agent (e.g. Claude Code, Codex, Amp). OpenCode models are grouped by `OpenCode ()` to preserve their native provider grouping. Each model keeps its real model ID, and variants are exposed when available (Codex/OpenCode/Amp).
## Endpoint Coverage
@@ -132,7 +133,7 @@ See the full endpoint compatibility table below. Most endpoints are functional f
| `POST /permission/{id}/reply` | ✓ | Respond to permission requests |
| `GET /question` | ✓ | List pending questions |
| `POST /question/{id}/reply` | ✓ | Answer agent questions |
-| `GET /provider` | − | Returns provider metadata |
+| `GET /provider` | ✓ | Returns provider metadata |
| `GET /agent` | − | Returns agent list |
| `GET /config` | − | Returns config |
| *other endpoints* | − | Return empty/stub responses |
diff --git a/server/packages/gigacode/Cargo.toml b/gigacode/Cargo.toml
similarity index 100%
rename from server/packages/gigacode/Cargo.toml
rename to gigacode/Cargo.toml
diff --git a/server/packages/gigacode/README.md b/gigacode/README.md
similarity index 51%
rename from server/packages/gigacode/README.md
rename to gigacode/README.md
index 25da901..ab3bc85 100644
--- a/server/packages/gigacode/README.md
+++ b/gigacode/README.md
@@ -1,17 +1,22 @@
-# GigaCode
+
+
+
-Use [OpenCode](https://opencode.ai)'s UI with any coding agent.
+Supports Claude Code, Codex, and Amp.
-Supports Claude Code, Codex, and Amp.
+
+ This is not a fork (and never will be).
It's powered by Sandbox Agent SDK's wizardry.
Experimental & just for fun.
+
-This is __not__ a fork. It's powered by [Sandbox Agent SDK](https://sandboxagent.dev)'s wizardry.
+
+ Issues — Discord — Supported OpenCode Features
+
-> **Experimental**: This project is under active development. Please report bugs on [GitHub Issues](https://github.com/rivet-dev/sandbox-agent/issues) or join our [Discord](https://rivet.dev/discord).
## How It Works
```
-┌─ GigaCode ────────────────────────────────────────────────────────┐
+┌─ Gigacode ────────────────────────────────────────────────────────┐
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ OpenCode TUI │───▶│ Sandbox Agent │───▶│ Claude Code / │ │
│ │ │ │ │ │ Codex / Amp │ │
@@ -20,9 +25,21 @@ This is __not__ a fork. It's powered by [Sandbox Agent SDK](https://sandboxagent
```
- [Sandbox Agent SDK](https://sandboxagent.dev) provides a universal HTTP API for controlling Claude Code, Codex, and Amp
-- Sandbox Agent SDK exposes an [OpenCode-compatible endpoint](https://sandboxagent.dev/opencode-compatibility) so OpenCode can talk to any agent
+- Sandbox Agent SDK exposes an [OpenCode-compatible endpoint](https://sandboxagent.dev/docs/opencode-compatibility) so OpenCode can talk to any agent
- OpenCode connects to Sandbox Agent SDK via [`attach`](https://opencode.ai/docs/cli/#attach)
+## OpenCode Models vs Gigacode Agents
+
+- **OpenCode** supports **switching between inference providers** (Anthropic, OpenAI, etc.). This is OpenCode talking directly to the models with its own tools, system prompts, and agentic loop.
+- **Gigacode** automates other coding agent harnesses, so it's using the **exact same logic that you would if you ran Claude Code**, Codex, or Amp natively.
+
+```
+OpenCode (native): Model → OpenCode's tool loop → result
+Gigacode: Model → Claude Code / Codex / Amp CLI → result
+```
+
+This means you get each agent's specialized capabilities (such as Claude Code's `Read`/`Write`/`Bash` tools, Codex's sandboxed execution, and Amp's permission rules) rather than a single tool loop with different models behind it.
+
## Install
**macOS / Linux / WSL (Recommended)**
@@ -73,8 +90,8 @@ gigacode
**Web UI**
-Use the [OpenCode Web UI](https://sandboxagent.dev/opencode-compatibility) to control any coding agent from the browser.
+Use the [OpenCode Web UI](https://sandboxagent.dev/docs/opencode-compatibility) to control any coding agent from the browser.
**OpenCode SDK**
-Use the [`@opencode-ai/sdk`](https://sandboxagent.dev/opencode-compatibility) to programmatically control any coding agent.
+Use the [`@opencode-ai/sdk`](https://sandboxagent.dev/docs/opencode-compatibility) to programmatically control any coding agent.
diff --git a/server/packages/gigacode/src/main.rs b/gigacode/src/main.rs
similarity index 100%
rename from server/packages/gigacode/src/main.rs
rename to gigacode/src/main.rs
diff --git a/justfile b/justfile
index 107139b..714768c 100644
--- a/justfile
+++ b/justfile
@@ -66,15 +66,15 @@ install:
pnpm install
pnpm build --filter @sandbox-agent/inspector...
cargo install --path server/packages/sandbox-agent --debug
- cargo install --path server/packages/gigacode --debug
+ cargo install --path gigacode --debug
install-fast:
SANDBOX_AGENT_SKIP_INSPECTOR=1 cargo install --path server/packages/sandbox-agent --debug
- SANDBOX_AGENT_SKIP_INSPECTOR=1 cargo install --path server/packages/gigacode --debug
+ SANDBOX_AGENT_SKIP_INSPECTOR=1 cargo install --path gigacode --debug
install-release:
pnpm install
pnpm build --filter @sandbox-agent/inspector...
cargo install --path server/packages/sandbox-agent
- cargo install --path server/packages/gigacode
+ cargo install --path gigacode
diff --git a/server/packages/sandbox-agent/src/cli.rs b/server/packages/sandbox-agent/src/cli.rs
index 724468c..fe69cd2 100644
--- a/server/packages/sandbox-agent/src/cli.rs
+++ b/server/packages/sandbox-agent/src/cli.rs
@@ -580,7 +580,7 @@ fn run_api(command: &ApiCommand, cli: &CliConfig) -> Result<(), CliError> {
}
fn run_opencode(cli: &CliConfig, args: &OpencodeArgs) -> Result<(), CliError> {
- let name = if cli.gigacode { "GigaCode" } else { "OpenCode command" };
+ let name = if cli.gigacode { "Gigacode" } else { "OpenCode command" };
write_stderr_line(&format!("EXPERIMENTAL: Please report bugs to:\n- GitHub: https://github.com/rivet-dev/sandbox-agent/issues\n- Discord: https://rivet.dev/discord\n\n{name} is powered by:- OpenCode (TUI): https://opencode.ai/\n- Sandbox Agent SDK (multi-agent compatibility): https://sandboxagent.dev/\n\n"))?;
let token = cli.token.clone();
diff --git a/server/packages/sandbox-agent/src/opencode_compat.rs b/server/packages/sandbox-agent/src/opencode_compat.rs
index d91d26c..ac72d0c 100644
--- a/server/packages/sandbox-agent/src/opencode_compat.rs
+++ b/server/packages/sandbox-agent/src/opencode_compat.rs
@@ -4,7 +4,7 @@
//! stubbed responses with deterministic helpers for snapshot testing. A minimal
//! in-memory state tracks sessions/messages/ptys to keep behavior coherent.
-use std::collections::HashMap;
+use std::collections::{BTreeMap, HashMap, HashSet};
use std::convert::Infallible;
use std::str::FromStr;
use std::sync::atomic::{AtomicU64, Ordering};
@@ -37,9 +37,8 @@ static MESSAGE_COUNTER: AtomicU64 = AtomicU64::new(1);
static PART_COUNTER: AtomicU64 = AtomicU64::new(1);
static PTY_COUNTER: AtomicU64 = AtomicU64::new(1);
static PROJECT_COUNTER: AtomicU64 = AtomicU64::new(1);
-const OPENCODE_PROVIDER_ID: &str = "sandbox-agent";
-const OPENCODE_PROVIDER_NAME: &str = "Sandbox Agent";
const OPENCODE_DEFAULT_MODEL_ID: &str = "mock";
+const OPENCODE_DEFAULT_PROVIDER_ID: &str = "mock";
const OPENCODE_DEFAULT_AGENT_MODE: &str = "build";
#[derive(Clone, Debug)]
@@ -220,14 +219,19 @@ struct OpenCodeSessionRuntime {
#[derive(Clone, Debug)]
struct OpenCodeModelEntry {
- agent: AgentId,
model: AgentModelInfo,
+ group_id: String,
+ group_name: String,
}
#[derive(Clone, Debug)]
struct OpenCodeModelCache {
entries: Vec,
model_lookup: HashMap,
+ group_defaults: HashMap,
+ group_agents: HashMap,
+ group_names: HashMap,
+ default_group: String,
default_model: String,
}
@@ -390,13 +394,17 @@ async fn ensure_backing_session(
state: &Arc,
session_id: &str,
agent: &str,
+ model: Option,
+ variant: Option,
) -> Result<(), SandboxError> {
+ let model = model.filter(|value| !value.trim().is_empty());
+ let variant = variant.filter(|value| !value.trim().is_empty());
let request = CreateSessionRequest {
agent: agent.to_string(),
agent_mode: None,
permission_mode: None,
- model: None,
- variant: None,
+ model: model.clone(),
+ variant: variant.clone(),
agent_version: None,
};
match state
@@ -406,7 +414,17 @@ async fn ensure_backing_session(
.await
{
Ok(_) => Ok(()),
- Err(SandboxError::SessionAlreadyExists { .. }) => Ok(()),
+ Err(SandboxError::SessionAlreadyExists { .. }) => {
+ state
+ .inner
+ .session_manager()
+ .set_session_overrides(session_id, model, variant)
+ .await
+ .or_else(|err| match err {
+ SandboxError::SessionNotFound { .. } => Ok(()),
+ other => Err(other),
+ })
+ }
Err(err) => Err(err),
}
}
@@ -623,6 +641,10 @@ async fn opencode_model_cache(state: &OpenCodeAppState) -> OpenCodeModelCache {
async fn build_opencode_model_cache(state: &OpenCodeAppState) -> OpenCodeModelCache {
let mut entries = Vec::new();
let mut model_lookup = HashMap::new();
+ let mut ambiguous_models = HashSet::new();
+ let mut group_defaults: HashMap = HashMap::new();
+ let mut group_agents: HashMap = HashMap::new();
+ let mut group_names: HashMap = HashMap::new();
let mut default_model: Option = None;
for agent in available_agent_ids() {
@@ -631,32 +653,97 @@ async fn build_opencode_model_cache(state: &OpenCodeAppState) -> OpenCodeModelCa
Err(_) => continue,
};
- if default_model.is_none() {
- default_model = response
- .default_model
- .clone()
- .or_else(|| response.models.first().map(|model| model.id.clone()));
- }
-
+ let first_model_id = 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;
+ let (group_id, group_name) = group_for_agent_model(agent, &model_id);
+
+ if response.default_model.as_deref() == Some(model_id.as_str()) {
+ group_defaults.insert(group_id.clone(), model_id.clone());
}
- model_lookup.insert(model_id.clone(), agent);
- entries.push(OpenCodeModelEntry { agent, model });
+
+ group_agents.entry(group_id.clone()).or_insert(agent);
+ group_names
+ .entry(group_id.clone())
+ .or_insert_with(|| group_name.clone());
+
+ if !ambiguous_models.contains(&model_id) {
+ match model_lookup.get(&model_id) {
+ None => {
+ model_lookup.insert(model_id.clone(), agent);
+ }
+ Some(existing) if *existing != agent => {
+ model_lookup.remove(&model_id);
+ ambiguous_models.insert(model_id.clone());
+ }
+ _ => {}
+ }
+ }
+
+ entries.push(OpenCodeModelEntry {
+ model,
+ group_id,
+ group_name,
+ });
+ }
+
+ if default_model.is_none() {
+ default_model = response.default_model.clone().or(first_model_id);
}
}
- 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())
- };
+ let mut groups: BTreeMap> = BTreeMap::new();
+ for entry in &entries {
+ groups
+ .entry(entry.group_id.clone())
+ .or_default()
+ .push(entry);
+ }
+ for entries in groups.values_mut() {
+ entries.sort_by(|a, b| a.model.id.cmp(&b.model.id));
+ }
+
+ if entries
+ .iter()
+ .any(|entry| entry.model.id == OPENCODE_DEFAULT_MODEL_ID)
+ {
+ default_model = Some(OPENCODE_DEFAULT_MODEL_ID.to_string());
+ }
+
+ let default_model = default_model.unwrap_or_else(|| {
+ entries
+ .first()
+ .map(|entry| entry.model.id.clone())
+ .unwrap_or_else(|| OPENCODE_DEFAULT_MODEL_ID.to_string())
+ });
+
+ let mut default_group = entries
+ .iter()
+ .find(|entry| entry.model.id == default_model)
+ .map(|entry| entry.group_id.clone())
+ .unwrap_or_else(|| OPENCODE_DEFAULT_PROVIDER_ID.to_string());
+
+ if !groups.contains_key(&default_group) {
+ if let Some((group_id, _)) = groups.iter().next() {
+ default_group = group_id.clone();
+ }
+ }
+
+ for (group_id, entries) in &groups {
+ if !group_defaults.contains_key(group_id) {
+ if let Some(entry) = entries.first() {
+ group_defaults.insert(group_id.clone(), entry.model.id.clone());
+ }
+ }
+ }
OpenCodeModelCache {
entries,
model_lookup,
+ group_defaults,
+ group_agents,
+ group_names,
+ default_group,
default_model,
}
}
@@ -666,8 +753,8 @@ fn resolve_agent_from_model(
provider_id: &str,
model_id: &str,
) -> Option {
- if provider_id != OPENCODE_PROVIDER_ID {
- return None;
+ if let Some(agent) = cache.group_agents.get(provider_id) {
+ return Some(*agent);
}
if let Some(agent) = cache.model_lookup.get(model_id) {
return Some(*agent);
@@ -675,6 +762,9 @@ fn resolve_agent_from_model(
if let Some(agent) = AgentId::parse(model_id) {
return Some(agent);
}
+ if opencode_group_provider(provider_id).is_some() {
+ return Some(AgentId::Opencode);
+ }
if model_id.contains('/') {
return Some(AgentId::Opencode);
}
@@ -706,15 +796,31 @@ async fn resolve_session_agent(
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 != "sandbox-agent")
+ .map(|value| value.to_string());
+ let model_id = requested_model
.filter(|value| !value.is_empty())
- .unwrap_or(default_model_id.as_str())
- .to_string();
+ .map(|value| value.to_string());
+ if provider_id.is_none() {
+ if let Some(model_value) = model_id.as_deref() {
+ if let Some(entry) = cache
+ .entries
+ .iter()
+ .find(|entry| entry.model.id == model_value)
+ {
+ provider_id = Some(entry.group_id.clone());
+ } else if let Some(agent) = AgentId::parse(model_value) {
+ provider_id = Some(agent.as_str().to_string());
+ }
+ }
+ }
+ let mut provider_id = provider_id.unwrap_or_else(|| cache.default_group.clone());
+ let mut model_id = model_id
+ .or_else(|| cache.group_defaults.get(&provider_id).cloned())
+ .unwrap_or_else(|| default_model_id.clone());
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();
+ provider_id = cache.default_group.clone();
model_id = default_model_id.clone();
resolved_agent =
resolve_agent_from_model(&cache, &provider_id, &model_id).or_else(|| Some(default_agent_id()));
@@ -756,19 +862,63 @@ fn agent_display_name(agent: AgentId) -> &'static str {
}
}
-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);
+fn opencode_model_provider(model_id: &str) -> Option<&str> {
+ model_id.split_once('/').map(|(provider, _)| provider)
+}
+
+fn opencode_group_provider(group_id: &str) -> Option<&str> {
+ group_id.strip_prefix("opencode:")
+}
+
+fn group_for_agent_model(agent: AgentId, model_id: &str) -> (String, String) {
+ if agent == AgentId::Opencode {
+ let provider = opencode_model_provider(model_id).unwrap_or("unknown");
+ return (
+ format!("opencode:{provider}"),
+ format!("OpenCode ({provider})"),
+ );
+ }
+ let group_id = agent.as_str().to_string();
+ let group_name = agent_display_name(agent).to_string();
+ (group_id, group_name)
+}
+
+fn backing_model_for_agent(agent: AgentId, provider_id: &str, model_id: &str) -> Option {
+ if model_id.trim().is_empty() {
+ return None;
+ }
+ if AgentId::parse(model_id).is_some() {
+ return None;
+ }
+ if agent != AgentId::Opencode {
+ return Some(model_id.to_string());
+ }
+ if model_id.contains('/') {
+ return Some(model_id.to_string());
+ }
+ if let Some(provider) = opencode_group_provider(provider_id) {
+ return Some(format!("{provider}/{model_id}"));
+ }
+ Some(model_id.to_string())
+}
+
+fn model_config_entry(entry: &OpenCodeModelEntry) -> Value {
+ let model_name = entry
+ .model
+ .name
+ .clone()
+ .unwrap_or_else(|| entry.model.id.clone());
+ let variants = model_variants_object(&entry.model);
json!({
- "id": model.id,
- "providerID": OPENCODE_PROVIDER_ID,
+ "id": entry.model.id,
+ "providerID": entry.group_id,
"api": {
"id": "sandbox-agent",
"url": "http://localhost",
"npm": "@sandbox-agent/sdk"
},
"name": model_name,
- "family": agent_display_name(agent),
+ "family": entry.group_name,
"capabilities": {
"temperature": true,
"reasoning": true,
@@ -807,13 +957,17 @@ fn model_config_entry(agent: AgentId, model: &AgentModelInfo) -> 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);
+fn model_summary_entry(entry: &OpenCodeModelEntry) -> Value {
+ let model_name = entry
+ .model
+ .name
+ .clone()
+ .unwrap_or_else(|| entry.model.id.clone());
+ let variants = model_variants_object(&entry.model);
json!({
- "id": model.id,
+ "id": entry.model.id,
"name": model_name,
- "family": agent_display_name(agent),
+ "family": entry.group_name,
"release_date": "2024-01-01",
"attachment": false,
"reasoning": true,
@@ -1673,7 +1827,7 @@ async fn apply_item_event(
let provider_id = runtime
.last_model_provider
.clone()
- .unwrap_or_else(|| OPENCODE_PROVIDER_ID.to_string());
+ .unwrap_or_else(|| OPENCODE_DEFAULT_PROVIDER_ID.to_string());
let model_id = runtime
.last_model_id
.clone()
@@ -1984,7 +2138,7 @@ async fn apply_tool_item_event(
let provider_id = runtime
.last_model_provider
.clone()
- .unwrap_or_else(|| OPENCODE_PROVIDER_ID.to_string());
+ .unwrap_or_else(|| OPENCODE_DEFAULT_PROVIDER_ID.to_string());
let model_id = runtime
.last_model_id
.clone()
@@ -2218,7 +2372,7 @@ async fn apply_item_delta(
let provider_id = runtime
.last_model_provider
.clone()
- .unwrap_or_else(|| OPENCODE_PROVIDER_ID.to_string());
+ .unwrap_or_else(|| OPENCODE_DEFAULT_PROVIDER_ID.to_string());
let model_id = runtime
.last_model_id
.clone()
@@ -2420,7 +2574,7 @@ pub fn build_opencode_router(state: Arc) -> Router {
)]
async fn oc_agent_list(State(state): State>) -> impl IntoResponse {
let agent = json!({
- "name": OPENCODE_PROVIDER_NAME,
+ "name": "Sandbox Agent",
"description": "Sandbox Agent compatibility layer",
"mode": "all",
"native": false,
@@ -2472,28 +2626,41 @@ async fn oc_config_providers(
State(state): State>,
) -> impl IntoResponse {
let cache = opencode_model_cache(&state).await;
- let mut models = serde_json::Map::new();
+ let mut grouped: BTreeMap> = BTreeMap::new();
for entry in &cache.entries {
- models.insert(
- entry.model.id.clone(),
- model_config_entry(entry.agent, &entry.model),
- );
+ grouped
+ .entry(entry.group_id.clone())
+ .or_default()
+ .push(entry);
+ }
+ let mut providers = Vec::new();
+ let mut defaults = serde_json::Map::new();
+ for (group_id, entries) in grouped {
+ let mut models = serde_json::Map::new();
+ for entry in entries {
+ models.insert(entry.model.id.clone(), model_config_entry(entry));
+ }
+ let name = cache
+ .group_names
+ .get(&group_id)
+ .cloned()
+ .unwrap_or_else(|| group_id.clone());
+ providers.push(json!({
+ "id": group_id,
+ "name": name,
+ "source": "custom",
+ "env": [],
+ "key": "",
+ "options": {},
+ "models": Value::Object(models),
+ }));
+ if let Some(default_model) = cache.group_defaults.get(&group_id) {
+ defaults.insert(group_id, Value::String(default_model.clone()));
+ }
}
let providers = json!({
- "providers": [
- {
- "id": OPENCODE_PROVIDER_ID,
- "name": OPENCODE_PROVIDER_NAME,
- "source": "custom",
- "env": [],
- "key": "",
- "options": {},
- "models": Value::Object(models),
- }
- ],
- "default": {
- OPENCODE_PROVIDER_ID: cache.default_model
- }
+ "providers": providers,
+ "default": Value::Object(defaults),
});
(StatusCode::OK, Json(providers))
}
@@ -3144,6 +3311,9 @@ async fn oc_session_message_create(
.and_then(|v| v.as_str());
let (session_agent, provider_id, model_id) =
resolve_session_agent(&state, &session_id, requested_provider, requested_model).await;
+ let session_agent_id = AgentId::parse(&session_agent).unwrap_or_else(default_agent_id);
+ let backing_model = backing_model_for_agent(session_agent_id, &provider_id, &model_id);
+ let backing_variant = body.variant.clone();
let parts_input = body.parts.unwrap_or_default();
if parts_input.is_empty() {
@@ -3207,7 +3377,15 @@ async fn oc_session_message_create(
})
.await;
- if let Err(err) = ensure_backing_session(&state, &session_id, &session_agent).await {
+ if let Err(err) = ensure_backing_session(
+ &state,
+ &session_id,
+ &session_agent,
+ backing_model,
+ backing_variant,
+ )
+ .await
+ {
tracing::warn!(
target = "sandbox_agent::opencode",
?err,
@@ -3413,7 +3591,7 @@ async fn oc_session_command(
&directory,
&worktree,
&agent,
- OPENCODE_PROVIDER_ID,
+ OPENCODE_DEFAULT_PROVIDER_ID,
OPENCODE_DEFAULT_MODEL_ID,
);
@@ -3463,7 +3641,7 @@ async fn oc_session_shell(
.as_ref()
.and_then(|v| v.get("providerID"))
.and_then(|v| v.as_str())
- .unwrap_or(OPENCODE_PROVIDER_ID),
+ .unwrap_or(OPENCODE_DEFAULT_PROVIDER_ID),
body.model
.as_ref()
.and_then(|v| v.get("modelID"))
@@ -3775,26 +3953,41 @@ async fn oc_provider_list(
State(state): State>,
) -> impl IntoResponse {
let cache = opencode_model_cache(&state).await;
- let mut models = serde_json::Map::new();
+ let mut grouped: BTreeMap> = BTreeMap::new();
for entry in &cache.entries {
- models.insert(
- entry.model.id.clone(),
- model_summary_entry(entry.agent, &entry.model),
- );
+ grouped
+ .entry(entry.group_id.clone())
+ .or_default()
+ .push(entry);
+ }
+ let mut providers = Vec::new();
+ let mut defaults = serde_json::Map::new();
+ let mut connected = Vec::new();
+ for (group_id, entries) in grouped {
+ let mut models = serde_json::Map::new();
+ for entry in entries {
+ models.insert(entry.model.id.clone(), model_summary_entry(entry));
+ }
+ let name = cache
+ .group_names
+ .get(&group_id)
+ .cloned()
+ .unwrap_or_else(|| group_id.clone());
+ providers.push(json!({
+ "id": group_id,
+ "name": name,
+ "env": [],
+ "models": Value::Object(models),
+ }));
+ if let Some(default_model) = cache.group_defaults.get(&group_id) {
+ defaults.insert(group_id.clone(), Value::String(default_model.clone()));
+ }
+ connected.push(group_id);
}
let providers = json!({
- "all": [
- {
- "id": OPENCODE_PROVIDER_ID,
- "name": OPENCODE_PROVIDER_NAME,
- "env": [],
- "models": Value::Object(models),
- }
- ],
- "default": {
- OPENCODE_PROVIDER_ID: cache.default_model
- },
- "connected": [OPENCODE_PROVIDER_ID]
+ "all": providers,
+ "default": Value::Object(defaults),
+ "connected": connected
});
(StatusCode::OK, Json(providers))
}
@@ -3805,11 +3998,15 @@ async fn oc_provider_list(
responses((status = 200)),
tag = "opencode"
)]
-async fn oc_provider_auth() -> impl IntoResponse {
- let auth = json!({
- OPENCODE_PROVIDER_ID: []
- });
- (StatusCode::OK, Json(auth))
+async fn oc_provider_auth(
+ State(state): State>,
+) -> impl IntoResponse {
+ let cache = opencode_model_cache(&state).await;
+ let mut auth_map = serde_json::Map::new();
+ for group_id in cache.group_names.keys() {
+ auth_map.insert(group_id.clone(), json!([]));
+ }
+ (StatusCode::OK, Json(Value::Object(auth_map)))
}
#[utoipa::path(
diff --git a/server/packages/sandbox-agent/src/router.rs b/server/packages/sandbox-agent/src/router.rs
index 3746240..d585eee 100644
--- a/server/packages/sandbox-agent/src/router.rs
+++ b/server/packages/sandbox-agent/src/router.rs
@@ -1692,6 +1692,27 @@ impl SessionManager {
})
}
+ pub(crate) async fn set_session_overrides(
+ &self,
+ session_id: &str,
+ model: Option,
+ variant: Option,
+ ) -> Result<(), SandboxError> {
+ let mut sessions = self.sessions.lock().await;
+ let Some(session) = SessionManager::session_mut(&mut sessions, session_id) else {
+ return Err(SandboxError::SessionNotFound {
+ session_id: session_id.to_string(),
+ });
+ };
+ if let Some(model) = model {
+ session.model = Some(model);
+ }
+ if let Some(variant) = variant {
+ session.variant = Some(variant);
+ }
+ Ok(())
+ }
+
async fn agent_modes(&self, agent: AgentId) -> Result, SandboxError> {
if agent != AgentId::Opencode {
return Ok(agent_modes_for(agent));
diff --git a/server/packages/sandbox-agent/tests/opencode-compat/events.test.ts b/server/packages/sandbox-agent/tests/opencode-compat/events.test.ts
index 61ab1f3..2140ef3 100644
--- a/server/packages/sandbox-agent/tests/opencode-compat/events.test.ts
+++ b/server/packages/sandbox-agent/tests/opencode-compat/events.test.ts
@@ -115,7 +115,7 @@ describe("OpenCode-compatible Event Streaming", () => {
await client.session.prompt({
path: { id: sessionId },
body: {
- model: { providerID: "sandbox-agent", modelID: "mock" },
+ model: { providerID: "mock", modelID: "mock" },
parts: [{ type: "text", text: "Say hello" }],
},
});
@@ -180,7 +180,7 @@ describe("OpenCode-compatible Event Streaming", () => {
await client.session.prompt({
path: { id: sessionId },
body: {
- model: { providerID: "sandbox-agent", modelID: "mock" },
+ model: { providerID: "mock", modelID: "mock" },
parts: [{ type: "text", text: "echo hello" }],
},
});
@@ -223,7 +223,7 @@ describe("OpenCode-compatible Event Streaming", () => {
await client.session.prompt({
path: { id: sessionId },
body: {
- model: { providerID: "sandbox-agent", modelID: "mock" },
+ model: { providerID: "mock", modelID: "mock" },
parts: [{ type: "text", text: "tool" }],
},
});
diff --git a/server/packages/sandbox-agent/tests/opencode-compat/messaging.test.ts b/server/packages/sandbox-agent/tests/opencode-compat/messaging.test.ts
index f45db83..ce37371 100644
--- a/server/packages/sandbox-agent/tests/opencode-compat/messaging.test.ts
+++ b/server/packages/sandbox-agent/tests/opencode-compat/messaging.test.ts
@@ -45,7 +45,7 @@ describe("OpenCode-compatible Messaging API", () => {
const response = await client.session.prompt({
path: { id: sessionId },
body: {
- model: { providerID: "sandbox-agent", modelID: "mock" },
+ model: { providerID: "mock", modelID: "mock" },
parts: [{ type: "text", text: "Hello, world!" }],
},
});
@@ -58,7 +58,7 @@ describe("OpenCode-compatible Messaging API", () => {
const response = await client.session.prompt({
path: { id: sessionId },
body: {
- model: { providerID: "sandbox-agent", modelID: "mock" },
+ model: { providerID: "mock", modelID: "mock" },
parts: [{ type: "text", text: "Say hello" }],
},
});
@@ -72,7 +72,7 @@ describe("OpenCode-compatible Messaging API", () => {
const response = await client.session.promptAsync({
path: { id: sessionId },
body: {
- model: { providerID: "sandbox-agent", modelID: "mock" },
+ model: { providerID: "mock", modelID: "mock" },
parts: [{ type: "text", text: "Process this asynchronously" }],
},
});
@@ -96,7 +96,7 @@ describe("OpenCode-compatible Messaging API", () => {
await client.session.prompt({
path: { id: sessionId },
body: {
- model: { providerID: "sandbox-agent", modelID: "mock" },
+ model: { providerID: "mock", modelID: "mock" },
parts: [{ type: "text", text: "Test message" }],
},
});
@@ -116,7 +116,7 @@ describe("OpenCode-compatible Messaging API", () => {
await client.session.prompt({
path: { id: sessionId },
body: {
- model: { providerID: "sandbox-agent", modelID: "mock" },
+ model: { providerID: "mock", modelID: "mock" },
parts: [{ type: "text", text: "Test" }],
},
});
@@ -144,7 +144,7 @@ describe("OpenCode-compatible Messaging API", () => {
await client.session.promptAsync({
path: { id: sessionId },
body: {
- model: { providerID: "sandbox-agent", modelID: "mock" },
+ model: { providerID: "mock", modelID: "mock" },
parts: [{ type: "text", text: "Long running task" }],
},
});
diff --git a/server/packages/sandbox-agent/tests/opencode-compat/models.test.ts b/server/packages/sandbox-agent/tests/opencode-compat/models.test.ts
index 68ef0c0..076fd49 100644
--- a/server/packages/sandbox-agent/tests/opencode-compat/models.test.ts
+++ b/server/packages/sandbox-agent/tests/opencode-compat/models.test.ts
@@ -28,22 +28,25 @@ describe("OpenCode-compatible Model API", () => {
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 providers = response.data?.all ?? [];
+ const mockProvider = providers.find((entry) => entry.id === "mock");
+ const ampProvider = providers.find((entry) => entry.id === "amp");
+ const sandboxProvider = providers.find((entry) => entry.id === "sandbox-agent");
+ expect(sandboxProvider).toBeUndefined();
+ expect(mockProvider).toBeDefined();
+ expect(ampProvider).toBeDefined();
- const models = provider?.models ?? {};
- const modelIds = Object.keys(models);
- expect(modelIds.length).toBeGreaterThan(0);
+ const mockModels = mockProvider?.models ?? {};
+ expect(mockModels["mock"]).toBeDefined();
+ expect(mockModels["mock"].id).toBe("mock");
+ expect(mockModels["mock"].family).toBe("Mock");
- expect(models["mock"]).toBeDefined();
- expect(models["mock"].id).toBe("mock");
- expect(models["mock"].family).toBe("Mock");
+ const ampModels = ampProvider?.models ?? {};
+ expect(ampModels["smart"]).toBeDefined();
+ expect(ampModels["smart"].id).toBe("smart");
+ expect(ampModels["smart"].family).toBe("Amp");
- 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");
+ expect(response.data?.default?.["mock"]).toBe("mock");
+ expect(response.data?.default?.["amp"]).toBe("smart");
});
});
diff --git a/server/packages/sandbox-agent/tests/opencode-compat/permissions.test.ts b/server/packages/sandbox-agent/tests/opencode-compat/permissions.test.ts
index 097d9fe..0742da7 100644
--- a/server/packages/sandbox-agent/tests/opencode-compat/permissions.test.ts
+++ b/server/packages/sandbox-agent/tests/opencode-compat/permissions.test.ts
@@ -57,7 +57,7 @@ describe("OpenCode-compatible Permission API", () => {
it("should receive permission.asked and reply via global endpoint", async () => {
await client.session.prompt({
sessionID: sessionId,
- model: { providerID: "sandbox-agent", modelID: "mock" },
+ model: { providerID: "mock", modelID: "mock" },
parts: [{ type: "text", text: permissionPrompt }],
});
@@ -77,7 +77,7 @@ describe("OpenCode-compatible Permission API", () => {
it("should accept permission response for a session", async () => {
await client.session.prompt({
sessionID: sessionId,
- model: { providerID: "sandbox-agent", modelID: "mock" },
+ model: { providerID: "mock", modelID: "mock" },
parts: [{ type: "text", text: permissionPrompt }],
});
diff --git a/server/packages/sandbox-agent/tests/opencode-compat/questions.test.ts b/server/packages/sandbox-agent/tests/opencode-compat/questions.test.ts
index ae881fb..4868f98 100644
--- a/server/packages/sandbox-agent/tests/opencode-compat/questions.test.ts
+++ b/server/packages/sandbox-agent/tests/opencode-compat/questions.test.ts
@@ -49,7 +49,7 @@ describe("OpenCode-compatible Question API", () => {
it("should ask a question and accept a reply", async () => {
await client.session.prompt({
sessionID: sessionId,
- model: { providerID: "sandbox-agent", modelID: "mock" },
+ model: { providerID: "mock", modelID: "mock" },
parts: [{ type: "text", text: questionPrompt }],
});
@@ -67,7 +67,7 @@ describe("OpenCode-compatible Question API", () => {
it("should allow rejecting a question", async () => {
await client.session.prompt({
sessionID: sessionId,
- model: { providerID: "sandbox-agent", modelID: "mock" },
+ model: { providerID: "mock", modelID: "mock" },
parts: [{ type: "text", text: questionPrompt }],
});
diff --git a/server/packages/sandbox-agent/tests/opencode-compat/tools.test.ts b/server/packages/sandbox-agent/tests/opencode-compat/tools.test.ts
index 4cdda8f..ea45950 100644
--- a/server/packages/sandbox-agent/tests/opencode-compat/tools.test.ts
+++ b/server/packages/sandbox-agent/tests/opencode-compat/tools.test.ts
@@ -72,7 +72,7 @@ describe("OpenCode-compatible Tool + File Actions", () => {
await client.session.prompt({
path: { id: sessionId },
body: {
- model: { providerID: "sandbox-agent", modelID: "mock" },
+ model: { providerID: "mock", modelID: "mock" },
parts: [{ type: "text", text: "tool" }],
},
});