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

+

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

+ Gigacode. Use OpenCode's UI with any coding agent. +

-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. +

+ IssuesDiscordSupported 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" }], }, });