chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,485 @@
|
||||
"""Synthesize OpenCode provider config for the native-server harness.
|
||||
|
||||
Unlike codex/claude/pi — which consume ``HARNESS_*_GATEWAY_*`` env vars that
|
||||
their CLIs translate into provider config — OpenCode reads its provider/auth
|
||||
from its own config file under the per-session ``XDG_CONFIG_HOME``. So routing
|
||||
opencode-native through the Databricks AI gateway (or any OpenAI-compatible
|
||||
endpoint) means writing an ``opencode.json`` into the runner-owned
|
||||
``opencode serve``'s config dir at spawn, declaring a custom
|
||||
``@ai-sdk/openai-compatible`` provider pointed at ``{host}/serving-endpoints``.
|
||||
|
||||
The model is then referenced as ``<provider_id>/<endpoint>`` per prompt.
|
||||
|
||||
Security: the file carries a bearer token, so it is written ``0600`` into the
|
||||
per-session XDG dir (never the user's global ``~/.config/opencode``). The token
|
||||
is resolved at spawn; a resumed session re-spawns the server and re-resolves, so
|
||||
short-lived gateway tokens refresh on resume (documented limitation: a token
|
||||
that expires mid-session is not refreshed in place).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from omnigent.spec.types import MCPServerConfig
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Provider id used in the synthesized opencode.json for the Databricks gateway.
|
||||
# The per-prompt model is pinned as ``{DATABRICKS_GATEWAY_PROVIDER_ID}/<endpoint>``.
|
||||
DATABRICKS_GATEWAY_PROVIDER_ID = "databricks-gateway"
|
||||
DATABRICKS_GATEWAY_PROVIDER_NAME = "Databricks AI Gateway"
|
||||
# Endpoint that exposes the workspace's OpenAI-compatible chat completions.
|
||||
_SERVING_ENDPOINTS_PATH = "serving-endpoints"
|
||||
# Fallback chat model when neither the spec nor config names one.
|
||||
DEFAULT_DATABRICKS_GATEWAY_MODEL = "databricks-claude-sonnet-4-6"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpenCodeGatewayResolution:
|
||||
"""A resolved OpenAI-compatible gateway for the opencode-native harness.
|
||||
|
||||
:param base_url: OpenAI-compatible base URL, e.g.
|
||||
``"https://ws.cloud.databricks.com/serving-endpoints"``.
|
||||
:param api_key: Bearer token / API key for the gateway.
|
||||
:param model_id: The endpoint/model id, e.g. ``"databricks-claude-sonnet-4-6"``.
|
||||
:param provider_id: opencode provider id, e.g. ``"databricks-gateway"``.
|
||||
:param provider_name: Human label for the opencode provider block.
|
||||
"""
|
||||
|
||||
base_url: str
|
||||
api_key: str
|
||||
model_id: str
|
||||
provider_id: str = DATABRICKS_GATEWAY_PROVIDER_ID
|
||||
provider_name: str = DATABRICKS_GATEWAY_PROVIDER_NAME
|
||||
|
||||
@property
|
||||
def qualified_model(self) -> str:
|
||||
""":returns: The per-prompt ``provider/model`` id opencode expects."""
|
||||
return f"{self.provider_id}/{self.model_id}"
|
||||
|
||||
|
||||
def build_opencode_model_default_config(model: str) -> dict[str, object]:
|
||||
"""
|
||||
Build a minimal ``opencode.json`` that only pins the default model.
|
||||
|
||||
Used when the user's own provider auth (``opencode auth login`` /
|
||||
provider env keys) already supplies credentials, but a default model has
|
||||
been chosen — via ``omni opencode --model`` or the ``omni setup`` OpenCode
|
||||
default — so the per-session TUI (and the first turn) launch on that model
|
||||
instead of OpenCode's built-in default (``opencode/big-pickle``). No
|
||||
provider block: OpenCode resolves the provider from the model id's prefix
|
||||
against its own ``auth.json``.
|
||||
|
||||
:param model: A ``provider/model`` id, e.g. ``"anthropic/claude-sonnet-4-5"``.
|
||||
:returns: A config dict ready to serialize to ``opencode.json``.
|
||||
"""
|
||||
return {"$schema": "https://opencode.ai/config.json", "model": model}
|
||||
|
||||
|
||||
def build_opencode_provider_config(resolution: OpenCodeGatewayResolution) -> dict[str, object]:
|
||||
"""
|
||||
Build the ``opencode.json`` declaring a custom OpenAI-compatible provider.
|
||||
|
||||
:param resolution: The resolved gateway (base URL + key + model).
|
||||
:returns: A config dict ready to serialize to ``opencode.json``.
|
||||
"""
|
||||
return {
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
resolution.provider_id: {
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"name": resolution.provider_name,
|
||||
"options": {
|
||||
"baseURL": resolution.base_url,
|
||||
"apiKey": resolution.api_key,
|
||||
},
|
||||
"models": {resolution.model_id: {"name": resolution.model_id}},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def write_opencode_provider_config(xdg_config_home: Path, config: Mapping[str, object]) -> Path:
|
||||
"""
|
||||
Atomically write ``<xdg_config_home>/opencode/opencode.json`` (``0600``).
|
||||
|
||||
:param xdg_config_home: The per-session ``XDG_CONFIG_HOME`` the server uses.
|
||||
:param config: The provider config dict (see
|
||||
:func:`build_opencode_provider_config`).
|
||||
:returns: The path written.
|
||||
"""
|
||||
cfg_dir = xdg_config_home / "opencode"
|
||||
cfg_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
path = cfg_dir / "opencode.json"
|
||||
payload = json.dumps(config, indent=2, sort_keys=True) + "\n"
|
||||
fd, tmp_name = tempfile.mkstemp(prefix="opencode.json.", dir=str(cfg_dir))
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
handle.write(payload)
|
||||
os.chmod(tmp_name, 0o600)
|
||||
os.replace(tmp_name, path)
|
||||
finally:
|
||||
if os.path.exists(tmp_name):
|
||||
os.unlink(tmp_name)
|
||||
return path
|
||||
|
||||
|
||||
def build_opencode_mcp_block(
|
||||
servers: Sequence[MCPServerConfig],
|
||||
) -> dict[str, dict[str, object]]:
|
||||
"""
|
||||
Translate Omnigent MCP server declarations into opencode.json's ``mcp`` block.
|
||||
|
||||
Mirrors how codex/claude expose the agent's MCP servers, but via opencode's
|
||||
own config (no relay): ``stdio`` → ``{type:"local", command:[cmd, *args],
|
||||
environment, enabled}``; ``http`` → ``{type:"remote", url, headers,
|
||||
enabled}``. A ``databricks_profile`` resolves a bearer token into the
|
||||
``Authorization`` header at spawn (re-resolved on resume, like the gateway
|
||||
provider). Entries opencode can't represent (missing command / url) are
|
||||
skipped.
|
||||
|
||||
:param servers: The agent spec's ``mcp_servers``.
|
||||
:returns: An opencode ``mcp`` block keyed by server name (empty when none
|
||||
are representable).
|
||||
"""
|
||||
block: dict[str, dict[str, object]] = {}
|
||||
for server in servers:
|
||||
name = getattr(server, "name", None)
|
||||
if not name:
|
||||
continue
|
||||
if getattr(server, "transport", "http") == "stdio":
|
||||
command = getattr(server, "command", None)
|
||||
if not command:
|
||||
continue
|
||||
entry: dict[str, object] = {
|
||||
"type": "local",
|
||||
"command": [command, *getattr(server, "args", [])],
|
||||
"enabled": True,
|
||||
}
|
||||
env = dict(getattr(server, "env", {}) or {})
|
||||
if env:
|
||||
entry["environment"] = env
|
||||
else:
|
||||
url = getattr(server, "url", None)
|
||||
if not url:
|
||||
continue
|
||||
headers = dict(getattr(server, "headers", {}) or {})
|
||||
profile = getattr(server, "databricks_profile", None)
|
||||
if profile and "Authorization" not in headers:
|
||||
token = _databricks_bearer_token(profile)
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
entry = {"type": "remote", "url": url, "enabled": True}
|
||||
if headers:
|
||||
entry["headers"] = headers
|
||||
block[str(name)] = entry
|
||||
return block
|
||||
|
||||
|
||||
def build_opencode_omnigent_mcp_server(
|
||||
bridge_dir: Path, *, python_executable: str | None = None
|
||||
) -> dict[str, dict[str, object]]:
|
||||
"""
|
||||
Build the opencode ``mcp`` entry that connects opencode to Omnigent's MCP.
|
||||
|
||||
This is what makes opencode's model call the Omnigent builtin tools
|
||||
(``sys_session_*``, ``sys_agent_*``, ``load_skill``, ``web_fetch``,
|
||||
``list_comments``/``update_comment``, policy tools, …). opencode launches the
|
||||
SHARED ``omnigent.claude_native_bridge serve-mcp`` as a ``{type:"local"}``
|
||||
stdio MCP server (the same relay codex/cursor/qwen use); ``serve-mcp`` reads
|
||||
the relay URL+token from ``tool_relay.json`` in *bridge_dir* (written by the
|
||||
runner's comment relay) and proxies each tool call back through the Omnigent
|
||||
server, where policy is enforced. The command is sourced from
|
||||
:func:`claude_native_bridge.build_mcp_config` so the invocation stays in one
|
||||
place.
|
||||
|
||||
:param bridge_dir: OpenCode-native bridge directory (must hold ``bridge.json``
|
||||
+ ``tool_relay.json``).
|
||||
:param python_executable: Python to run ``serve-mcp`` with; ``None`` uses the
|
||||
runner interpreter (has ``omnigent`` importable).
|
||||
:returns: A one-entry ``mcp`` block ``{"omnigent": {type:"local", …}}``.
|
||||
"""
|
||||
from omnigent.claude_native_bridge import build_mcp_config
|
||||
|
||||
claude_cfg = build_mcp_config(bridge_dir, python_executable=python_executable)
|
||||
# build_mcp_config returns {"mcpServers": {"<name>": {command, args, env}}};
|
||||
# opencode wants a flat command list + ``environment``.
|
||||
name, server = next(iter(claude_cfg["mcpServers"].items()))
|
||||
entry: dict[str, object] = {
|
||||
"type": "local",
|
||||
"command": [server["command"], *server.get("args", [])],
|
||||
"enabled": True,
|
||||
}
|
||||
env = dict(server.get("env", {}) or {})
|
||||
if env:
|
||||
entry["environment"] = env
|
||||
return {str(name): entry}
|
||||
|
||||
|
||||
def _databricks_bearer_token(profile: str) -> str | None:
|
||||
"""Resolve a bearer token for a ``~/.databrickscfg`` profile (best-effort)."""
|
||||
try:
|
||||
from databricks.sdk.core import Config
|
||||
|
||||
headers = Config(profile=profile).authenticate() or {}
|
||||
authz = headers.get("Authorization", "")
|
||||
return authz.split(" ", 1)[1] if authz.lower().startswith("bearer ") else None
|
||||
except Exception as exc: # noqa: BLE001 - SDK absent / bad profile / auth failure.
|
||||
_logger.info("opencode MCP databricks token resolve failed for %r: %r", profile, exc)
|
||||
return None
|
||||
|
||||
|
||||
def resolve_databricks_gateway(
|
||||
profile: str | None,
|
||||
*,
|
||||
model_id: str | None = None,
|
||||
) -> OpenCodeGatewayResolution | None:
|
||||
"""
|
||||
Resolve a Databricks AI gateway for opencode from a ``~/.databrickscfg`` profile.
|
||||
|
||||
Uses ``databricks-sdk`` (the ``databricks`` extra) to obtain the workspace
|
||||
host + a bearer token for *profile*, then targets the workspace's
|
||||
OpenAI-compatible ``/serving-endpoints``. Best-effort: returns ``None`` when
|
||||
the SDK is absent, the profile is unknown, or auth fails — the caller then
|
||||
leaves opencode on its ambient provider config.
|
||||
|
||||
:param profile: A ``~/.databrickscfg`` profile name, e.g. ``"oss"``;
|
||||
``None`` short-circuits.
|
||||
:param model_id: Endpoint/model id to pin; defaults to
|
||||
:data:`DEFAULT_DATABRICKS_GATEWAY_MODEL` (a ``databricks-*`` chat
|
||||
endpoint the gateway routes).
|
||||
:returns: A resolution, or ``None`` when the gateway can't be resolved.
|
||||
"""
|
||||
if not profile:
|
||||
return None
|
||||
try:
|
||||
from databricks.sdk.core import Config
|
||||
|
||||
config = Config(profile=profile)
|
||||
host = (config.host or "").rstrip("/")
|
||||
if not host:
|
||||
return None
|
||||
headers = config.authenticate() or {}
|
||||
authz = headers.get("Authorization", "")
|
||||
token = authz.split(" ", 1)[1] if authz.lower().startswith("bearer ") else ""
|
||||
if not token:
|
||||
return None
|
||||
except Exception as exc: # noqa: BLE001 - SDK absent / auth failure / bad profile.
|
||||
_logger.info("opencode Databricks gateway resolve failed for %r: %r", profile, exc)
|
||||
return None
|
||||
|
||||
resolved_model = _gateway_endpoint_for_model(model_id) or DEFAULT_DATABRICKS_GATEWAY_MODEL
|
||||
return OpenCodeGatewayResolution(
|
||||
base_url=f"{host}/{_SERVING_ENDPOINTS_PATH}",
|
||||
api_key=token,
|
||||
model_id=resolved_model,
|
||||
)
|
||||
|
||||
|
||||
def _gateway_endpoint_for_model(model_id: str | None) -> str | None:
|
||||
"""
|
||||
Normalize a spec model id to a Databricks serving-endpoint name.
|
||||
|
||||
Accepts ``"databricks-claude-..."`` and ``"databricks/claude-..."`` spellings
|
||||
and strips a leading ``databricks/`` provider prefix; anything that does not
|
||||
look like a ``databricks-*`` endpoint is ignored (the gateway only routes
|
||||
its own endpoint names), so the default applies.
|
||||
|
||||
:param model_id: The spec/override model id, or ``None``.
|
||||
:returns: A bare endpoint name, or ``None``.
|
||||
"""
|
||||
if not model_id:
|
||||
return None
|
||||
candidate = model_id.split("/", 1)[1] if model_id.startswith("databricks/") else model_id
|
||||
return candidate if candidate.startswith("databricks-") else None
|
||||
|
||||
|
||||
def _strip_jsonc_comments(text: str) -> str:
|
||||
"""
|
||||
Strip ``//`` line comments and ``/* */`` block comments from JSONC text.
|
||||
|
||||
Uses a character-level state machine to track string boundaries, so
|
||||
``//`` inside string literals (e.g. URLs like ``"https://example.com"``)
|
||||
are never mistaken for comments.
|
||||
"""
|
||||
result: list[str] = []
|
||||
i = 0
|
||||
length = len(text)
|
||||
in_string = False
|
||||
string_char: str | None = None
|
||||
|
||||
while i < length:
|
||||
ch = text[i]
|
||||
|
||||
if in_string:
|
||||
if ch == "\\":
|
||||
result.append(ch)
|
||||
i += 1
|
||||
if i < length:
|
||||
result.append(text[i])
|
||||
i += 1
|
||||
elif ch == string_char:
|
||||
in_string = False
|
||||
result.append(ch)
|
||||
i += 1
|
||||
else:
|
||||
result.append(ch)
|
||||
i += 1
|
||||
elif ch in ('"', "'"):
|
||||
in_string = True
|
||||
string_char = ch
|
||||
result.append(ch)
|
||||
i += 1
|
||||
elif ch == "/" and i + 1 < length:
|
||||
next_ch = text[i + 1]
|
||||
if next_ch == "/":
|
||||
i += 2
|
||||
while i < length and text[i] != "\n":
|
||||
i += 1
|
||||
elif next_ch == "*":
|
||||
i += 2
|
||||
while i + 1 < length:
|
||||
if text[i] == "*" and text[i + 1] == "/":
|
||||
i += 2
|
||||
break
|
||||
i += 1
|
||||
else:
|
||||
result.append(ch)
|
||||
i += 1
|
||||
else:
|
||||
result.append(ch)
|
||||
i += 1
|
||||
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def _strip_trailing_commas(text: str) -> str:
|
||||
"""Remove trailing commas before ``}`` or ``]`` (valid in JSONC, invalid in JSON).
|
||||
|
||||
Operates on text that has already had its JSONC comments stripped, so the
|
||||
only commas present are real JSON commas. Uses a character-level state
|
||||
machine that tracks string boundaries so that ``, }`` or ``, ]`` inside
|
||||
quoted values are never mistaken for trailing commas — preventing silent
|
||||
corruption of provider options like ``"note": "a, }"``.
|
||||
"""
|
||||
result: list[str] = []
|
||||
i = 0
|
||||
length = len(text)
|
||||
in_string = False
|
||||
string_char: str | None = None
|
||||
|
||||
while i < length:
|
||||
ch = text[i]
|
||||
|
||||
if in_string:
|
||||
if ch == "\\":
|
||||
result.append(ch)
|
||||
i += 1
|
||||
if i < length:
|
||||
result.append(text[i])
|
||||
i += 1
|
||||
elif ch == string_char:
|
||||
in_string = False
|
||||
result.append(ch)
|
||||
i += 1
|
||||
else:
|
||||
result.append(ch)
|
||||
i += 1
|
||||
elif ch in ('"', "'"):
|
||||
in_string = True
|
||||
string_char = ch
|
||||
result.append(ch)
|
||||
i += 1
|
||||
elif ch == ",":
|
||||
j = i + 1
|
||||
while j < length and text[j] in (" ", "\t", "\n", "\r"):
|
||||
j += 1
|
||||
if j < length and text[j] in ("}", "]"):
|
||||
i = j
|
||||
else:
|
||||
result.append(ch)
|
||||
i += 1
|
||||
else:
|
||||
result.append(ch)
|
||||
i += 1
|
||||
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def maybe_merge_user_provider_config(config: dict[str, object]) -> dict[str, object]:
|
||||
"""
|
||||
Merge the user's global OpenCode provider definitions into *config*.
|
||||
|
||||
OpenCode reads ``XDG_CONFIG_HOME/opencode/opencode.json(c)`` for custom
|
||||
provider definitions (e.g. OpenAI-compatible endpoints with custom base
|
||||
URLs). When running under Omnigent, the per-session ``XDG_CONFIG_HOME``
|
||||
override hides this global config. This function reads the user's real
|
||||
config and merges any ``provider`` block into *config* so the spawned
|
||||
server sees both the user's providers (with their custom base URLs) and
|
||||
any Omnigent-synthesized providers (e.g. Databricks gateway).
|
||||
|
||||
Only ``provider`` entries are merged — the synthesized config takes
|
||||
precedence for all other keys (model, mcp, plugin, permission, etc.).
|
||||
|
||||
:param config: The synthesized config dict (may be empty).
|
||||
:returns: *config* with user's ``provider`` entries merged in (if any).
|
||||
"""
|
||||
from omnigent.opencode_native_bridge import user_opencode_config_path
|
||||
|
||||
user_path = user_opencode_config_path()
|
||||
if user_path is None:
|
||||
return config
|
||||
|
||||
try:
|
||||
raw = user_path.read_text(encoding="utf-8")
|
||||
# Try plain JSON first (handles .json files without comments).
|
||||
# If that fails, strip JSONC comments and trailing commas, then
|
||||
# retry (handles .jsonc).
|
||||
try:
|
||||
user_config = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
cleaned = _strip_jsonc_comments(raw)
|
||||
cleaned = _strip_trailing_commas(cleaned)
|
||||
user_config = json.loads(cleaned)
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return config
|
||||
except json.JSONDecodeError:
|
||||
_logger.warning(
|
||||
"Failed to parse user OpenCode config at %s — ignoring user providers",
|
||||
user_path,
|
||||
)
|
||||
return config
|
||||
|
||||
if not isinstance(user_config, dict):
|
||||
return config
|
||||
|
||||
user_providers = user_config.get("provider")
|
||||
if not isinstance(user_providers, dict) or not user_providers:
|
||||
return config
|
||||
|
||||
result = dict(config)
|
||||
existing = result.get("provider")
|
||||
if isinstance(existing, dict):
|
||||
# Merge user's providers alongside existing ones; don't clobber
|
||||
# synthesized providers (Omnigent's keys like "databricks-gateway"
|
||||
# take priority).
|
||||
merged = dict(existing)
|
||||
for key, value in user_providers.items():
|
||||
if key not in merged:
|
||||
merged[key] = value
|
||||
result["provider"] = merged
|
||||
else:
|
||||
result["provider"] = dict(user_providers)
|
||||
|
||||
result.setdefault("$schema", "https://opencode.ai/config.json")
|
||||
|
||||
return result
|
||||
Reference in New Issue
Block a user