chore: update readme (#98)

This commit is contained in:
Nathan Flurry 2026-02-06 03:03:24 -08:00 committed by GitHub
parent c0800e1a43
commit dc2a2b1687
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 379 additions and 136 deletions

BIN
.github/media/gigacode-header.jpeg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 628 KiB

View file

@ -83,7 +83,7 @@ SANDBOX_AGENT_SKIP_INSPECTOR=1 pnpm --filter @sandbox-agent/opencode-compat-test
## Naming ## 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 ## Git Commits

View file

@ -1,6 +1,6 @@
[workspace] [workspace]
resolver = "2" resolver = "2"
members = ["server/packages/*"] members = ["server/packages/*", "gigacode"]
[workspace.package] [workspace.package]
version = "0.1.6" version = "0.1.6"

View file

@ -12,6 +12,10 @@
<a href="https://sandboxagent.dev/docs">Documentation</a><a href="https://sandboxagent.dev/docs/api-reference">API Reference</a><a href="https://rivet.dev/discord">Discord</a> <a href="https://sandboxagent.dev/docs">Documentation</a><a href="https://sandboxagent.dev/docs/api-reference">API Reference</a><a href="https://rivet.dev/discord">Discord</a>
</p> </p>
<p align="center">
<em><strong>Experimental:</strong> <a href="./gigacode/">Gigacode</a> — use OpenCode's TUI with any coding agent.</em>
</p>
## Why Sandbox 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. 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.

View file

@ -1,6 +1,6 @@
--- ---
title: GigaCode title: Gigacode
url: "https://github.com/rivet-dev/sandbox-agent/tree/main/server/packages/gigacode" url: "https://github.com/rivet-dev/sandbox-agent/tree/main/gigacode"
--- ---

View file

@ -112,6 +112,7 @@ for await (const event of events.stream) {
- **Authentication**: If sandbox-agent is started with `--token`, include `Authorization: Bearer <token>` header or use `--password` flag with CLI - **Authentication**: If sandbox-agent is started with `--token`, include `Authorization: Bearer <token>` header or use `--password` flag with CLI
- **CORS**: When using the web UI from a different origin, configure `--cors-allow-origin` - **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) - **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 (<provider>)` to preserve their native provider grouping. Each model keeps its real model ID, and variants are exposed when available (Codex/OpenCode/Amp).
## Endpoint Coverage ## 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 | | `POST /permission/{id}/reply` | ✓ | Respond to permission requests |
| `GET /question` | ✓ | List pending questions | | `GET /question` | ✓ | List pending questions |
| `POST /question/{id}/reply` | ✓ | Answer agent questions | | `POST /question/{id}/reply` | ✓ | Answer agent questions |
| `GET /provider` | | Returns provider metadata | | `GET /provider` | | Returns provider metadata |
| `GET /agent` | | Returns agent list | | `GET /agent` | | Returns agent list |
| `GET /config` | | Returns config | | `GET /config` | | Returns config |
| *other endpoints* | | Return empty/stub responses | | *other endpoints* | | Return empty/stub responses |

View file

@ -1,17 +1,22 @@
# GigaCode <p align="center">
<img src="../.github/media/gigacode-header.jpeg" alt="Gigacode. Use OpenCode's UI with any coding agent." />
</p>
Use [OpenCode](https://opencode.ai)'s UI with any coding agent. <h3 align="center">Supports Claude Code, Codex, and Amp.</h3>
Supports Claude Code, Codex, and Amp. <p align="center">
<i>This is <u>not</u> a fork (and never will be).<br/>It's powered by <a href="https://sandboxagent.dev">Sandbox Agent SDK</a>'s wizardry.<br/>Experimental & just for fun.</i>
</p>
This is __not__ a fork. It's powered by [Sandbox Agent SDK](https://sandboxagent.dev)'s wizardry. <p align="center">
<a href="https://github.com/rivet-dev/sandbox-agent/issues">Issues</a><a href="https://rivet.dev/discord">Discord</a><a href="https://sandboxagent.dev/docs/opencode-compatibility#endpoint-coverage">Supported OpenCode Features</a>
</p>
> **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 ## How It Works
``` ```
┌─ GigaCode ────────────────────────────────────────────────────────┐ ┌─ Gigacode ────────────────────────────────────────────────────────┐
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ OpenCode TUI │───▶│ Sandbox Agent │───▶│ Claude Code / │ │ │ │ OpenCode TUI │───▶│ Sandbox Agent │───▶│ Claude Code / │ │
│ │ │ │ │ │ Codex / Amp │ │ │ │ │ │ │ │ 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](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 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 ## Install
**macOS / Linux / WSL (Recommended)** **macOS / Linux / WSL (Recommended)**
@ -73,8 +90,8 @@ gigacode
**Web UI** **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** **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.

View file

@ -66,15 +66,15 @@ install:
pnpm install pnpm install
pnpm build --filter @sandbox-agent/inspector... pnpm build --filter @sandbox-agent/inspector...
cargo install --path server/packages/sandbox-agent --debug cargo install --path server/packages/sandbox-agent --debug
cargo install --path server/packages/gigacode --debug cargo install --path gigacode --debug
install-fast: 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/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: install-release:
pnpm install pnpm install
pnpm build --filter @sandbox-agent/inspector... pnpm build --filter @sandbox-agent/inspector...
cargo install --path server/packages/sandbox-agent cargo install --path server/packages/sandbox-agent
cargo install --path server/packages/gigacode cargo install --path gigacode

View file

@ -580,7 +580,7 @@ fn run_api(command: &ApiCommand, cli: &CliConfig) -> Result<(), CliError> {
} }
fn run_opencode(cli: &CliConfig, args: &OpencodeArgs) -> 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"))?; 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(); let token = cli.token.clone();

View file

@ -4,7 +4,7 @@
//! stubbed responses with deterministic helpers for snapshot testing. A minimal //! stubbed responses with deterministic helpers for snapshot testing. A minimal
//! in-memory state tracks sessions/messages/ptys to keep behavior coherent. //! 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::convert::Infallible;
use std::str::FromStr; use std::str::FromStr;
use std::sync::atomic::{AtomicU64, Ordering}; 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 PART_COUNTER: AtomicU64 = AtomicU64::new(1);
static PTY_COUNTER: AtomicU64 = AtomicU64::new(1); static PTY_COUNTER: AtomicU64 = AtomicU64::new(1);
static PROJECT_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_MODEL_ID: &str = "mock";
const OPENCODE_DEFAULT_PROVIDER_ID: &str = "mock";
const OPENCODE_DEFAULT_AGENT_MODE: &str = "build"; const OPENCODE_DEFAULT_AGENT_MODE: &str = "build";
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@ -220,14 +219,19 @@ struct OpenCodeSessionRuntime {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
struct OpenCodeModelEntry { struct OpenCodeModelEntry {
agent: AgentId,
model: AgentModelInfo, model: AgentModelInfo,
group_id: String,
group_name: String,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
struct OpenCodeModelCache { struct OpenCodeModelCache {
entries: Vec<OpenCodeModelEntry>, entries: Vec<OpenCodeModelEntry>,
model_lookup: HashMap<String, AgentId>, model_lookup: HashMap<String, AgentId>,
group_defaults: HashMap<String, String>,
group_agents: HashMap<String, AgentId>,
group_names: HashMap<String, String>,
default_group: String,
default_model: String, default_model: String,
} }
@ -390,13 +394,17 @@ async fn ensure_backing_session(
state: &Arc<OpenCodeAppState>, state: &Arc<OpenCodeAppState>,
session_id: &str, session_id: &str,
agent: &str, agent: &str,
model: Option<String>,
variant: Option<String>,
) -> Result<(), SandboxError> { ) -> Result<(), SandboxError> {
let model = model.filter(|value| !value.trim().is_empty());
let variant = variant.filter(|value| !value.trim().is_empty());
let request = CreateSessionRequest { let request = CreateSessionRequest {
agent: agent.to_string(), agent: agent.to_string(),
agent_mode: None, agent_mode: None,
permission_mode: None, permission_mode: None,
model: None, model: model.clone(),
variant: None, variant: variant.clone(),
agent_version: None, agent_version: None,
}; };
match state match state
@ -406,7 +414,17 @@ async fn ensure_backing_session(
.await .await
{ {
Ok(_) => Ok(()), 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), 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 { async fn build_opencode_model_cache(state: &OpenCodeAppState) -> OpenCodeModelCache {
let mut entries = Vec::new(); let mut entries = Vec::new();
let mut model_lookup = HashMap::new(); let mut model_lookup = HashMap::new();
let mut ambiguous_models = HashSet::new();
let mut group_defaults: HashMap<String, String> = HashMap::new();
let mut group_agents: HashMap<String, AgentId> = HashMap::new();
let mut group_names: HashMap<String, String> = HashMap::new();
let mut default_model: Option<String> = None; let mut default_model: Option<String> = None;
for agent in available_agent_ids() { for agent in available_agent_ids() {
@ -631,32 +653,97 @@ async fn build_opencode_model_cache(state: &OpenCodeAppState) -> OpenCodeModelCa
Err(_) => continue, Err(_) => continue,
}; };
if default_model.is_none() { let first_model_id = response.models.first().map(|model| model.id.clone());
default_model = response
.default_model
.clone()
.or_else(|| response.models.first().map(|model| model.id.clone()));
}
for model in response.models { for model in response.models {
let model_id = model.id.clone(); let model_id = model.id.clone();
if model_lookup.contains_key(&model_id) { let (group_id, group_name) = group_for_agent_model(agent, &model_id);
continue;
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) { let mut groups: BTreeMap<String, Vec<&OpenCodeModelEntry>> = BTreeMap::new();
OPENCODE_DEFAULT_MODEL_ID.to_string() for entry in &entries {
} else { groups
default_model.unwrap_or_else(|| OPENCODE_DEFAULT_MODEL_ID.to_string()) .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 { OpenCodeModelCache {
entries, entries,
model_lookup, model_lookup,
group_defaults,
group_agents,
group_names,
default_group,
default_model, default_model,
} }
} }
@ -666,8 +753,8 @@ fn resolve_agent_from_model(
provider_id: &str, provider_id: &str,
model_id: &str, model_id: &str,
) -> Option<AgentId> { ) -> Option<AgentId> {
if provider_id != OPENCODE_PROVIDER_ID { if let Some(agent) = cache.group_agents.get(provider_id) {
return None; return Some(*agent);
} }
if let Some(agent) = cache.model_lookup.get(model_id) { if let Some(agent) = cache.model_lookup.get(model_id) {
return Some(*agent); return Some(*agent);
@ -675,6 +762,9 @@ fn resolve_agent_from_model(
if let Some(agent) = AgentId::parse(model_id) { if let Some(agent) = AgentId::parse(model_id) {
return Some(agent); return Some(agent);
} }
if opencode_group_provider(provider_id).is_some() {
return Some(AgentId::Opencode);
}
if model_id.contains('/') { if model_id.contains('/') {
return Some(AgentId::Opencode); return Some(AgentId::Opencode);
} }
@ -706,15 +796,31 @@ async fn resolve_session_agent(
let default_model_id = cache.default_model.clone(); 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) .filter(|value| *value != "sandbox-agent")
.to_string(); .map(|value| value.to_string());
let mut model_id = requested_model let model_id = requested_model
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.unwrap_or(default_model_id.as_str()) .map(|value| value.to_string());
.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); 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 = cache.default_group.clone();
model_id = default_model_id.clone(); model_id = default_model_id.clone();
resolved_agent = resolved_agent =
resolve_agent_from_model(&cache, &provider_id, &model_id).or_else(|| Some(default_agent_id())); 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 { fn opencode_model_provider(model_id: &str) -> Option<&str> {
let model_name = model.name.clone().unwrap_or_else(|| model.id.clone()); model_id.split_once('/').map(|(provider, _)| provider)
let variants = model_variants_object(model); }
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<String> {
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!({ json!({
"id": model.id, "id": entry.model.id,
"providerID": OPENCODE_PROVIDER_ID, "providerID": entry.group_id,
"api": { "api": {
"id": "sandbox-agent", "id": "sandbox-agent",
"url": "http://localhost", "url": "http://localhost",
"npm": "@sandbox-agent/sdk" "npm": "@sandbox-agent/sdk"
}, },
"name": model_name, "name": model_name,
"family": agent_display_name(agent), "family": entry.group_name,
"capabilities": { "capabilities": {
"temperature": true, "temperature": true,
"reasoning": 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 { fn model_summary_entry(entry: &OpenCodeModelEntry) -> Value {
let model_name = model.name.clone().unwrap_or_else(|| model.id.clone()); let model_name = entry
let variants = model_variants_object(model); .model
.name
.clone()
.unwrap_or_else(|| entry.model.id.clone());
let variants = model_variants_object(&entry.model);
json!({ json!({
"id": model.id, "id": entry.model.id,
"name": model_name, "name": model_name,
"family": agent_display_name(agent), "family": entry.group_name,
"release_date": "2024-01-01", "release_date": "2024-01-01",
"attachment": false, "attachment": false,
"reasoning": true, "reasoning": true,
@ -1673,7 +1827,7 @@ async fn apply_item_event(
let provider_id = runtime let provider_id = runtime
.last_model_provider .last_model_provider
.clone() .clone()
.unwrap_or_else(|| OPENCODE_PROVIDER_ID.to_string()); .unwrap_or_else(|| OPENCODE_DEFAULT_PROVIDER_ID.to_string());
let model_id = runtime let model_id = runtime
.last_model_id .last_model_id
.clone() .clone()
@ -1984,7 +2138,7 @@ async fn apply_tool_item_event(
let provider_id = runtime let provider_id = runtime
.last_model_provider .last_model_provider
.clone() .clone()
.unwrap_or_else(|| OPENCODE_PROVIDER_ID.to_string()); .unwrap_or_else(|| OPENCODE_DEFAULT_PROVIDER_ID.to_string());
let model_id = runtime let model_id = runtime
.last_model_id .last_model_id
.clone() .clone()
@ -2218,7 +2372,7 @@ async fn apply_item_delta(
let provider_id = runtime let provider_id = runtime
.last_model_provider .last_model_provider
.clone() .clone()
.unwrap_or_else(|| OPENCODE_PROVIDER_ID.to_string()); .unwrap_or_else(|| OPENCODE_DEFAULT_PROVIDER_ID.to_string());
let model_id = runtime let model_id = runtime
.last_model_id .last_model_id
.clone() .clone()
@ -2420,7 +2574,7 @@ pub fn build_opencode_router(state: Arc<OpenCodeAppState>) -> Router {
)] )]
async fn oc_agent_list(State(state): State<Arc<OpenCodeAppState>>) -> impl IntoResponse { async fn oc_agent_list(State(state): State<Arc<OpenCodeAppState>>) -> impl IntoResponse {
let agent = json!({ let agent = json!({
"name": OPENCODE_PROVIDER_NAME, "name": "Sandbox Agent",
"description": "Sandbox Agent compatibility layer", "description": "Sandbox Agent compatibility layer",
"mode": "all", "mode": "all",
"native": false, "native": false,
@ -2472,28 +2626,41 @@ async fn oc_config_providers(
State(state): State<Arc<OpenCodeAppState>>, State(state): State<Arc<OpenCodeAppState>>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let cache = opencode_model_cache(&state).await; let cache = opencode_model_cache(&state).await;
let mut models = serde_json::Map::new(); let mut grouped: BTreeMap<String, Vec<&OpenCodeModelEntry>> = BTreeMap::new();
for entry in &cache.entries { for entry in &cache.entries {
models.insert( grouped
entry.model.id.clone(), .entry(entry.group_id.clone())
model_config_entry(entry.agent, &entry.model), .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!({ let providers = json!({
"providers": [ "providers": providers,
{ "default": Value::Object(defaults),
"id": OPENCODE_PROVIDER_ID,
"name": OPENCODE_PROVIDER_NAME,
"source": "custom",
"env": [],
"key": "",
"options": {},
"models": Value::Object(models),
}
],
"default": {
OPENCODE_PROVIDER_ID: cache.default_model
}
}); });
(StatusCode::OK, Json(providers)) (StatusCode::OK, Json(providers))
} }
@ -3144,6 +3311,9 @@ async fn oc_session_message_create(
.and_then(|v| v.as_str()); .and_then(|v| v.as_str());
let (session_agent, provider_id, model_id) = let (session_agent, provider_id, model_id) =
resolve_session_agent(&state, &session_id, requested_provider, requested_model).await; 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(); let parts_input = body.parts.unwrap_or_default();
if parts_input.is_empty() { if parts_input.is_empty() {
@ -3207,7 +3377,15 @@ async fn oc_session_message_create(
}) })
.await; .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!( tracing::warn!(
target = "sandbox_agent::opencode", target = "sandbox_agent::opencode",
?err, ?err,
@ -3413,7 +3591,7 @@ async fn oc_session_command(
&directory, &directory,
&worktree, &worktree,
&agent, &agent,
OPENCODE_PROVIDER_ID, OPENCODE_DEFAULT_PROVIDER_ID,
OPENCODE_DEFAULT_MODEL_ID, OPENCODE_DEFAULT_MODEL_ID,
); );
@ -3463,7 +3641,7 @@ async fn oc_session_shell(
.as_ref() .as_ref()
.and_then(|v| v.get("providerID")) .and_then(|v| v.get("providerID"))
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or(OPENCODE_PROVIDER_ID), .unwrap_or(OPENCODE_DEFAULT_PROVIDER_ID),
body.model body.model
.as_ref() .as_ref()
.and_then(|v| v.get("modelID")) .and_then(|v| v.get("modelID"))
@ -3775,26 +3953,41 @@ async fn oc_provider_list(
State(state): State<Arc<OpenCodeAppState>>, State(state): State<Arc<OpenCodeAppState>>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let cache = opencode_model_cache(&state).await; let cache = opencode_model_cache(&state).await;
let mut models = serde_json::Map::new(); let mut grouped: BTreeMap<String, Vec<&OpenCodeModelEntry>> = BTreeMap::new();
for entry in &cache.entries { for entry in &cache.entries {
models.insert( grouped
entry.model.id.clone(), .entry(entry.group_id.clone())
model_summary_entry(entry.agent, &entry.model), .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!({ let providers = json!({
"all": [ "all": providers,
{ "default": Value::Object(defaults),
"id": OPENCODE_PROVIDER_ID, "connected": connected
"name": OPENCODE_PROVIDER_NAME,
"env": [],
"models": Value::Object(models),
}
],
"default": {
OPENCODE_PROVIDER_ID: cache.default_model
},
"connected": [OPENCODE_PROVIDER_ID]
}); });
(StatusCode::OK, Json(providers)) (StatusCode::OK, Json(providers))
} }
@ -3805,11 +3998,15 @@ async fn oc_provider_list(
responses((status = 200)), responses((status = 200)),
tag = "opencode" tag = "opencode"
)] )]
async fn oc_provider_auth() -> impl IntoResponse { async fn oc_provider_auth(
let auth = json!({ State(state): State<Arc<OpenCodeAppState>>,
OPENCODE_PROVIDER_ID: [] ) -> impl IntoResponse {
}); let cache = opencode_model_cache(&state).await;
(StatusCode::OK, Json(auth)) 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( #[utoipa::path(

View file

@ -1692,6 +1692,27 @@ impl SessionManager {
}) })
} }
pub(crate) async fn set_session_overrides(
&self,
session_id: &str,
model: Option<String>,
variant: Option<String>,
) -> 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<Vec<AgentModeInfo>, SandboxError> { async fn agent_modes(&self, agent: AgentId) -> Result<Vec<AgentModeInfo>, SandboxError> {
if agent != AgentId::Opencode { if agent != AgentId::Opencode {
return Ok(agent_modes_for(agent)); return Ok(agent_modes_for(agent));

View file

@ -115,7 +115,7 @@ describe("OpenCode-compatible Event Streaming", () => {
await client.session.prompt({ await client.session.prompt({
path: { id: sessionId }, path: { id: sessionId },
body: { body: {
model: { providerID: "sandbox-agent", modelID: "mock" }, model: { providerID: "mock", modelID: "mock" },
parts: [{ type: "text", text: "Say hello" }], parts: [{ type: "text", text: "Say hello" }],
}, },
}); });
@ -180,7 +180,7 @@ describe("OpenCode-compatible Event Streaming", () => {
await client.session.prompt({ await client.session.prompt({
path: { id: sessionId }, path: { id: sessionId },
body: { body: {
model: { providerID: "sandbox-agent", modelID: "mock" }, model: { providerID: "mock", modelID: "mock" },
parts: [{ type: "text", text: "echo hello" }], parts: [{ type: "text", text: "echo hello" }],
}, },
}); });
@ -223,7 +223,7 @@ describe("OpenCode-compatible Event Streaming", () => {
await client.session.prompt({ await client.session.prompt({
path: { id: sessionId }, path: { id: sessionId },
body: { body: {
model: { providerID: "sandbox-agent", modelID: "mock" }, model: { providerID: "mock", modelID: "mock" },
parts: [{ type: "text", text: "tool" }], parts: [{ type: "text", text: "tool" }],
}, },
}); });

View file

@ -45,7 +45,7 @@ describe("OpenCode-compatible Messaging API", () => {
const response = await client.session.prompt({ const response = await client.session.prompt({
path: { id: sessionId }, path: { id: sessionId },
body: { body: {
model: { providerID: "sandbox-agent", modelID: "mock" }, model: { providerID: "mock", modelID: "mock" },
parts: [{ type: "text", text: "Hello, world!" }], parts: [{ type: "text", text: "Hello, world!" }],
}, },
}); });
@ -58,7 +58,7 @@ describe("OpenCode-compatible Messaging API", () => {
const response = await client.session.prompt({ const response = await client.session.prompt({
path: { id: sessionId }, path: { id: sessionId },
body: { body: {
model: { providerID: "sandbox-agent", modelID: "mock" }, model: { providerID: "mock", modelID: "mock" },
parts: [{ type: "text", text: "Say hello" }], parts: [{ type: "text", text: "Say hello" }],
}, },
}); });
@ -72,7 +72,7 @@ describe("OpenCode-compatible Messaging API", () => {
const response = await client.session.promptAsync({ const response = await client.session.promptAsync({
path: { id: sessionId }, path: { id: sessionId },
body: { body: {
model: { providerID: "sandbox-agent", modelID: "mock" }, model: { providerID: "mock", modelID: "mock" },
parts: [{ type: "text", text: "Process this asynchronously" }], parts: [{ type: "text", text: "Process this asynchronously" }],
}, },
}); });
@ -96,7 +96,7 @@ describe("OpenCode-compatible Messaging API", () => {
await client.session.prompt({ await client.session.prompt({
path: { id: sessionId }, path: { id: sessionId },
body: { body: {
model: { providerID: "sandbox-agent", modelID: "mock" }, model: { providerID: "mock", modelID: "mock" },
parts: [{ type: "text", text: "Test message" }], parts: [{ type: "text", text: "Test message" }],
}, },
}); });
@ -116,7 +116,7 @@ describe("OpenCode-compatible Messaging API", () => {
await client.session.prompt({ await client.session.prompt({
path: { id: sessionId }, path: { id: sessionId },
body: { body: {
model: { providerID: "sandbox-agent", modelID: "mock" }, model: { providerID: "mock", modelID: "mock" },
parts: [{ type: "text", text: "Test" }], parts: [{ type: "text", text: "Test" }],
}, },
}); });
@ -144,7 +144,7 @@ describe("OpenCode-compatible Messaging API", () => {
await client.session.promptAsync({ await client.session.promptAsync({
path: { id: sessionId }, path: { id: sessionId },
body: { body: {
model: { providerID: "sandbox-agent", modelID: "mock" }, model: { providerID: "mock", modelID: "mock" },
parts: [{ type: "text", text: "Long running task" }], parts: [{ type: "text", text: "Long running task" }],
}, },
}); });

View file

@ -28,22 +28,25 @@ describe("OpenCode-compatible Model API", () => {
it("should list models grouped by agent with real model IDs", async () => { it("should list models grouped by agent with real model IDs", async () => {
const response = await client.provider.list(); const response = await client.provider.list();
const provider = response.data?.all?.find((entry) => entry.id === "sandbox-agent"); const providers = response.data?.all ?? [];
expect(provider).toBeDefined(); 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 mockModels = mockProvider?.models ?? {};
const modelIds = Object.keys(models); expect(mockModels["mock"]).toBeDefined();
expect(modelIds.length).toBeGreaterThan(0); expect(mockModels["mock"].id).toBe("mock");
expect(mockModels["mock"].family).toBe("Mock");
expect(models["mock"]).toBeDefined(); const ampModels = ampProvider?.models ?? {};
expect(models["mock"].id).toBe("mock"); expect(ampModels["smart"]).toBeDefined();
expect(models["mock"].family).toBe("Mock"); expect(ampModels["smart"].id).toBe("smart");
expect(ampModels["smart"].family).toBe("Amp");
expect(models["smart"]).toBeDefined(); expect(response.data?.default?.["mock"]).toBe("mock");
expect(models["smart"].id).toBe("smart"); expect(response.data?.default?.["amp"]).toBe("smart");
expect(models["smart"].family).toBe("Amp");
expect(models["amp"]).toBeUndefined();
expect(response.data?.default?.["sandbox-agent"]).toBe("mock");
}); });
}); });

View file

@ -57,7 +57,7 @@ describe("OpenCode-compatible Permission API", () => {
it("should receive permission.asked and reply via global endpoint", async () => { it("should receive permission.asked and reply via global endpoint", async () => {
await client.session.prompt({ await client.session.prompt({
sessionID: sessionId, sessionID: sessionId,
model: { providerID: "sandbox-agent", modelID: "mock" }, model: { providerID: "mock", modelID: "mock" },
parts: [{ type: "text", text: permissionPrompt }], parts: [{ type: "text", text: permissionPrompt }],
}); });
@ -77,7 +77,7 @@ describe("OpenCode-compatible Permission API", () => {
it("should accept permission response for a session", async () => { it("should accept permission response for a session", async () => {
await client.session.prompt({ await client.session.prompt({
sessionID: sessionId, sessionID: sessionId,
model: { providerID: "sandbox-agent", modelID: "mock" }, model: { providerID: "mock", modelID: "mock" },
parts: [{ type: "text", text: permissionPrompt }], parts: [{ type: "text", text: permissionPrompt }],
}); });

View file

@ -49,7 +49,7 @@ describe("OpenCode-compatible Question API", () => {
it("should ask a question and accept a reply", async () => { it("should ask a question and accept a reply", async () => {
await client.session.prompt({ await client.session.prompt({
sessionID: sessionId, sessionID: sessionId,
model: { providerID: "sandbox-agent", modelID: "mock" }, model: { providerID: "mock", modelID: "mock" },
parts: [{ type: "text", text: questionPrompt }], parts: [{ type: "text", text: questionPrompt }],
}); });
@ -67,7 +67,7 @@ describe("OpenCode-compatible Question API", () => {
it("should allow rejecting a question", async () => { it("should allow rejecting a question", async () => {
await client.session.prompt({ await client.session.prompt({
sessionID: sessionId, sessionID: sessionId,
model: { providerID: "sandbox-agent", modelID: "mock" }, model: { providerID: "mock", modelID: "mock" },
parts: [{ type: "text", text: questionPrompt }], parts: [{ type: "text", text: questionPrompt }],
}); });

View file

@ -72,7 +72,7 @@ describe("OpenCode-compatible Tool + File Actions", () => {
await client.session.prompt({ await client.session.prompt({
path: { id: sessionId }, path: { id: sessionId },
body: { body: {
model: { providerID: "sandbox-agent", modelID: "mock" }, model: { providerID: "mock", modelID: "mock" },
parts: [{ type: "text", text: "tool" }], parts: [{ type: "text", text: "tool" }],
}, },
}); });