Files
omnigent-ai--omnigent/omnigent/opencode_native_provider.py
2026-07-13 13:12:00 +08:00

486 lines
18 KiB
Python

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