diff --git a/bridged/bridged.example.yaml b/bridged/bridged.example.yaml index 567fb2d..f02b183 100644 --- a/bridged/bridged.example.yaml +++ b/bridged/bridged.example.yaml @@ -102,6 +102,31 @@ workers: # tabLabel: "opencode: {profile} #{n}" # mcpUrl: http://127.0.0.1:8765/mcp # argv: ["opencode"] + # + # CB-508: point an opencode profile at your OWN OpenAI-compatible endpoint (local vLLM, llama.cpp, + # LM Studio, TGI…) instead of opencode's gateway. Setting `baseUrl` on a `kind: opencode` profile + # makes the bridge emit a custom `provider` block into the generated opencode.json — opencode has + # no ANTHROPIC_BASE_URL seam, so this is how the endpoint is pinned. + # baseUrl → a bare host:port gets `/v1` appended (where these servers mount the API); a URL that + # already has a path is used verbatim, so a custom mount point still works. + # model → MUST be "/". The provider half names the generated block; the model + # half must match an id the server reports at /v1/models. One field drives both the + # declaration and the `-m` flag, so they cannot drift apart. A bare model name with a + # baseUrl set is rejected at spawn rather than silently using the default gateway. + # tokenEnv → optional; its value becomes the provider apiKey. Most local servers ignore the key, + # so a placeholder is used when unset (the AI SDK still requires a non-empty one). + # NOTE: no `guard` entry is needed even with a baseUrl set. The SubscriptionGuard exists to stop a + # worker borrowing the primary's Anthropic subscription, and an opencode process has no Anthropic + # credential path at all. + # opencode-local: + # kind: opencode + # baseUrl: http://127.0.0.1:8000 + # model: local-vllm/deepseek-v4-flash + # placement: tab + # workspace: bridged-workers + # tabLabel: "opencode: {profile} #{n}" + # mcpUrl: http://127.0.0.1:8765/mcp + # argv: ["opencode"] defaultWorker: gx10 # Subscription boundary. A worker's base_url host MUST be one of these; the primary diff --git a/bridged/src/main/java/dev/ltms/bridged/worker/OpenCodeLauncher.java b/bridged/src/main/java/dev/ltms/bridged/worker/OpenCodeLauncher.java index 48b534e..b200a86 100644 --- a/bridged/src/main/java/dev/ltms/bridged/worker/OpenCodeLauncher.java +++ b/bridged/src/main/java/dev/ltms/bridged/worker/OpenCodeLauncher.java @@ -1,5 +1,7 @@ package dev.ltms.bridged.worker; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import dev.ltms.bridged.config.BridgedConfig; import dev.ltms.bridged.herdr.Agent; import dev.ltms.bridged.herdr.AgentControl; @@ -46,6 +48,9 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { /** Label prefix for this adapter's herdr agent names (drives naming + orphan reap). */ private static final String NAME_PREFIX = "opencode"; + /** Writer for the generated {@code opencode.json}. */ + private static final ObjectMapper JSON = new ObjectMapper(); + /** * Standing instruction written to the charter file and mounted via the config's * {@code instructions} so the worker returns its result through {@code bridge_reply}. Kept on @@ -134,13 +139,28 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { @Override protected Launch buildLaunch(BridgedConfig.Worker cfg) { Map workerEnv = newEnv(); - if (cfg.hasMcp()) { + // A config file is needed for the bridge MCP mount, for a pinned endpoint (CB-508), or both. + if (cfg.hasMcp() || hasCustomProvider(cfg)) { workerEnv.put("OPENCODE_CONFIG", writeConfig(cfg).toString()); } applyGitToken(workerEnv, cfg); return new Launch(workerEnv, argvWithModel(cfg)); } + /** + * True when this profile pins its own OpenAI-compatible endpoint (CB-508) rather than using + * whatever provider opencode resolves by default. + * + *

Note this reuses {@code baseUrl}, the same field the Claude adapter injects as + * {@code ANTHROPIC_BASE_URL} — but it does not go through {@code SubscriptionGuard}. + * That asymmetry is deliberate and safe: the guard exists to stop a worker borrowing the + * primary's Anthropic subscription, and an opencode process has no Anthropic credential path + * at all. Pointing it at a local vLLM cannot leak the subscription. + */ + private static boolean hasCustomProvider(BridgedConfig.Worker cfg) { + return cfg.baseUrl() != null && !cfg.baseUrl().isBlank(); + } + /** The launch argv plus, when a model is configured, the opencode {@code -m provider/model} flag. */ private List argvWithModel(BridgedConfig.Worker cfg) { List argv = mutableArgv(cfg.argv()); @@ -162,18 +182,28 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { Path dir = Files.createTempDirectory(configRoot, "bridged-opencode-"); dir.toFile().deleteOnExit(); - Path charter = dir.resolve("reply-charter.md"); - Files.writeString(charter, REPLY_CHARTER); - charter.toFile().deleteOnExit(); + ObjectNode root = JSON.createObjectNode(); + root.put("$schema", "https://opencode.ai/config.json"); + + if (cfg.hasMcp()) { + Path charter = dir.resolve("reply-charter.md"); + Files.writeString(charter, REPLY_CHARTER); + charter.toFile().deleteOnExit(); + + ObjectNode bridge = root.putObject("mcp").putObject("bridge"); + bridge.put("type", "remote"); + bridge.put("url", cfg.mcpUrl()); + bridge.put("enabled", true); + root.putArray("instructions").add(charter.toAbsolutePath().toString()); + } + if (hasCustomProvider(cfg)) { + addCustomProvider(root, cfg); + } - String json = "{\n" - + " \"$schema\": \"https://opencode.ai/config.json\",\n" - + " \"mcp\": { \"bridge\": { \"type\": \"remote\", \"url\": \"" - + jsonEscape(cfg.mcpUrl()) + "\", \"enabled\": true } },\n" - + " \"instructions\": [\"" + jsonEscape(charter.toAbsolutePath().toString()) + "\"]\n" - + "}\n"; Path cfgFile = dir.resolve("opencode.json"); - Files.writeString(cfgFile, json); + // Built with Jackson rather than string concatenation: the provider block is nested and + // carries operator-supplied values (URL, model id, api key), so escaping must be real. + Files.writeString(cfgFile, JSON.writerWithDefaultPrettyPrinter().writeValueAsString(root)); cfgFile.toFile().deleteOnExit(); return cfgFile; } catch (IOException e) { @@ -182,9 +212,62 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { } } - /** Minimal JSON string escaping for the two interpolated values (a URL and an absolute path). */ - private static String jsonEscape(String s) { - return s.replace("\\", "\\\\").replace("\"", "\\\""); + /** + * Declare a custom OpenAI-compatible provider so the worker talks to a pinned endpoint (a local + * vLLM, say) instead of opencode's default gateway (CB-508). + * + *

The provider id comes from the {@code provider/model} selector in {@code model:}, so one + * field drives both the declaration and the {@code -m} flag and they cannot drift apart. + */ + private void addCustomProvider(ObjectNode root, BridgedConfig.Worker cfg) { + String[] parts = splitModelSelector(cfg); + String providerId = parts[0]; + String modelId = parts[1]; + + ObjectNode provider = root.putObject("provider").putObject(providerId); + provider.put("npm", "@ai-sdk/openai-compatible"); + provider.put("name", providerId + " (bridged)"); + + ObjectNode options = provider.putObject("options"); + options.put("baseURL", openAiBaseUrl(cfg.baseUrl())); + // vLLM and friends usually ignore the key, but the AI SDK still requires a non-empty one. + String token = resolveEnv(cfg.tokenEnv()); + options.put("apiKey", (token == null || token.isBlank()) ? "bridged-local-noauth" : token); + + provider.putObject("models").putObject(modelId).put("name", modelId); + } + + /** + * Split {@code model:} into its {@code provider} and {@code model} halves. A pinned endpoint + * needs both, so a bare model name is rejected loudly rather than silently falling back to the + * default gateway — a worker quietly talking to the wrong endpoint is the failure this avoids. + */ + private static String[] splitModelSelector(BridgedConfig.Worker cfg) { + String model = cfg.model(); + int slash = model == null ? -1 : model.indexOf('/'); + if (model == null || model.isBlank() || slash <= 0 || slash == model.length() - 1) { + throw new IllegalArgumentException( + "profile " + cfg.profile() + " sets baseUrl (a pinned opencode endpoint) so" + + " model: must be \"/\", e.g." + + " \"local-vllm/deepseek-v4-flash\"; got " + + (model == null ? "null" : '"' + model + '"')); + } + return new String[]{model.substring(0, slash), model.substring(slash + 1)}; + } + + /** + * The OpenAI-compatible base URL for {@code baseUrl}. A bare {@code host:port} gets {@code /v1} + * appended (where these servers put the API); a URL that already carries a path is taken as-is, + * so an endpoint mounted somewhere unusual is still reachable. + */ + private static String openAiBaseUrl(String baseUrl) { + String trimmed = baseUrl.trim(); + while (trimmed.endsWith("/")) { + trimmed = trimmed.substring(0, trimmed.length() - 1); + } + int schemeEnd = trimmed.indexOf("://"); + String afterScheme = schemeEnd < 0 ? trimmed : trimmed.substring(schemeEnd + 3); + return afterScheme.contains("/") ? trimmed : trimmed + "/v1"; } // --- Agent-returning convenience spawns (used by callers/tests that want the herdr Agent) --- diff --git a/bridged/src/test/java/dev/ltms/bridged/worker/OpenCodeLauncherTest.java b/bridged/src/test/java/dev/ltms/bridged/worker/OpenCodeLauncherTest.java index d6768b9..f058a67 100644 --- a/bridged/src/test/java/dev/ltms/bridged/worker/OpenCodeLauncherTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/worker/OpenCodeLauncherTest.java @@ -1,5 +1,7 @@ package dev.ltms.bridged.worker; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import dev.ltms.bridged.config.BridgedConfig; import dev.ltms.bridged.herdr.AgentControl; import dev.ltms.bridged.herdr.FakeHerdr; @@ -65,10 +67,16 @@ class OpenCodeLauncherTest { assertNotNull(cfgPath, "OPENCODE_CONFIG points the worker at the generated config file"); assertTrue(Path.of(cfgPath).startsWith(root), "config file is generated under the injected root"); - String json = Files.readString(Path.of(cfgPath)); - assertTrue(json.contains("\"type\": \"remote\""), "bridge is mounted as a remote MCP server"); - assertTrue(json.contains("http://127.0.0.1:8765/mcp"), "the profile's bridge MCP url is present"); - assertTrue(json.contains("\"instructions\""), "the reply charter is mounted via instructions"); + // Assert on parsed structure, not substrings: the generated config is real JSON and its + // whitespace is the formatter's business, not the contract's. + JsonNode json = new ObjectMapper().readTree(Path.of(cfgPath).toFile()); + JsonNode bridge = json.path("mcp").path("bridge"); + assertEquals("remote", bridge.path("type").asText(), "bridge is mounted as a remote MCP server"); + assertEquals("http://127.0.0.1:8765/mcp", bridge.path("url").asText(), + "the profile's bridge MCP url is present"); + assertTrue(bridge.path("enabled").asBoolean(), "the bridge server is enabled"); + assertTrue(json.path("instructions").isArray() && !json.path("instructions").isEmpty(), + "the reply charter is mounted via instructions"); // The instructions entry is a real file path holding the reply charter. Path charter = Path.of(cfgPath).resolveSibling("reply-charter.md"); @@ -176,4 +184,86 @@ class OpenCodeLauncherTest { assertNotNull(handle, "spawn returns a handle when the gate is disabled"); assertFalse(herdr.called("agent.get"), "no polling when the gate is disabled"); } + + // --- CB-508: pinned OpenAI-compatible endpoint (e.g. a local vLLM) --------------------------- + + /** A profile with a baseUrl but no model provider prefix cannot be resolved — fail loudly. */ + private static BridgedConfig.Worker pinnedCfg(String model, String baseUrl, String mcpUrl) { + return new BridgedConfig.Worker("local", baseUrl, model, null, "BRIDGED_WORKER_TOKEN", + List.of("opencode"), "tab", "bridged-workers", "opencode: {model} #{n}", mcpUrl, + null, null, null, null, BridgedConfig.Worker.KIND_OPENCODE); + } + + @Test + void baseUrlDeclaresACustomOpenAiCompatibleProvider(@TempDir Path root) throws Exception { + FakeHerdr herdr = new FakeHerdr(); + service(herdr, root, pinnedCfg("local-vllm/deepseek-v4-flash", "http://127.0.0.1:8000", null)) + .spawn(); + + String cfgPath = startEnv(herdr).get("OPENCODE_CONFIG"); + assertNotNull(cfgPath, "a pinned endpoint needs a config file even with no bridge MCP url"); + JsonNode provider = new ObjectMapper().readTree(Path.of(cfgPath).toFile()) + .path("provider").path("local-vllm"); + + assertFalse(provider.isMissingNode(), "the provider id comes from the model selector"); + assertEquals("@ai-sdk/openai-compatible", provider.path("npm").asText()); + assertEquals("http://127.0.0.1:8000/v1", provider.path("options").path("baseURL").asText(), + "a bare host:port gets /v1 appended — that is where these servers mount the API"); + assertFalse(provider.path("options").path("apiKey").asText().isBlank(), + "the AI SDK requires a non-empty key even when the server ignores it"); + assertFalse(provider.path("models").path("deepseek-v4-flash").isMissingNode(), + "the model half of the selector is declared under the provider"); + } + + @Test + void aBaseUrlThatAlreadyCarriesAPathIsUsedVerbatim(@TempDir Path root) throws Exception { + FakeHerdr herdr = new FakeHerdr(); + service(herdr, root, pinnedCfg("local-vllm/m", "http://127.0.0.1:8000/openai/v1", null)).spawn(); + + JsonNode json = new ObjectMapper() + .readTree(Path.of(startEnv(herdr).get("OPENCODE_CONFIG")).toFile()); + assertEquals("http://127.0.0.1:8000/openai/v1", + json.path("provider").path("local-vllm").path("options").path("baseURL").asText(), + "an endpoint mounted on a custom path must not have /v1 bolted on"); + } + + @Test + void aPinnedEndpointRejectsAModelWithNoProviderPrefix(@TempDir Path root) { + FakeHerdr herdr = new FakeHerdr(); + OpenCodeLauncher launcher = + service(herdr, root, pinnedCfg("deepseek-v4-flash", "http://127.0.0.1:8000", null)); + + // Silently falling back to the default gateway would point the worker at the wrong LLM + // while looking healthy — the one failure mode worth being loud about. + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, launcher::spawn); + assertTrue(e.getMessage().contains("/"), "the error says how to fix it"); + } + + @Test + void aPinnedEndpointAndTheBridgeMcpCoexistInOneConfig(@TempDir Path root) throws Exception { + FakeHerdr herdr = new FakeHerdr(); + service(herdr, root, pinnedCfg("local-vllm/deepseek-v4-flash", + "http://127.0.0.1:8000", "http://127.0.0.1:8766/mcp")).spawn(); + + JsonNode json = new ObjectMapper() + .readTree(Path.of(startEnv(herdr).get("OPENCODE_CONFIG")).toFile()); + assertEquals("remote", json.path("mcp").path("bridge").path("type").asText(), + "pinning an endpoint must not drop the bridge MCP mount"); + assertFalse(json.path("provider").path("local-vllm").isMissingNode(), + "and the provider block is still declared alongside it"); + assertTrue(json.path("instructions").isArray() && !json.path("instructions").isEmpty(), + "the reply charter survives too"); + } + + @Test + void noBaseUrlDeclaresNoProviderSoTheDefaultGatewayIsUsed(@TempDir Path root) throws Exception { + FakeHerdr herdr = new FakeHerdr(); + service(herdr, root, opencodeCfg("opencode/some-free-model", "http://127.0.0.1:8766/mcp", null)) + .spawn(); + + JsonNode json = new ObjectMapper() + .readTree(Path.of(startEnv(herdr).get("OPENCODE_CONFIG")).toFile()); + assertTrue(json.path("provider").isMissingNode(), + "without a baseUrl opencode resolves its own provider as before"); + } }