diff --git a/litellm/proxy/opencode_endpoints/skills_endpoints.py b/litellm/proxy/opencode_endpoints/skills_endpoints.py index a4675c881ed..8542142e67a 100644 --- a/litellm/proxy/opencode_endpoints/skills_endpoints.py +++ b/litellm/proxy/opencode_endpoints/skills_endpoints.py @@ -1,6 +1,6 @@ import re -from fastapi import Depends, FastAPI, HTTPException, Response +from fastapi import Depends, FastAPI, HTTPException, Request, Response from litellm.llms.litellm_proxy.skills.handler import LiteLLMSkillsHandler from litellm.llms.litellm_proxy.skills.prompt_injection import ( @@ -10,6 +10,8 @@ from litellm.proxy._types import LiteLLM_SkillsTable, UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth OPENCODE_SKILLS_DEFAULT_PATH = "/opencode/skills" +OPENCODE_REMOTE_CONFIG_DEFAULT_PATH = "/.well-known/opencode" +OPENCODE_CONFIG_SCHEMA = "https://opencode.ai/config.json" AGENT_SKILLS_PATHS = ("/.well-known/agent-skills", "/.well-known/skills") _MAX_SKILLS = 1000 @@ -25,6 +27,14 @@ def _opencode_config_enabled(skills_gateway_config: dict | None) -> bool: ) +def _opencode_remote_config_enabled(skills_gateway_config: dict | None) -> bool: + if not _opencode_config_enabled(skills_gateway_config): + return False + opencode_config = skills_gateway_config.get("opencode", {}) + remote_config = opencode_config.get("remote_config", {}) + return isinstance(remote_config, dict) and remote_config.get("enabled") is True + + def _agent_skills_config_enabled(skills_gateway_config: dict | None) -> bool: if not isinstance(skills_gateway_config, dict): return False @@ -36,17 +46,38 @@ def _agent_skills_config_enabled(skills_gateway_config: dict | None) -> bool: ) +def _normalized_route_path(path: object, default: str) -> str: + path = str(path or default).strip() or default + if not path.startswith("/"): + path = f"/{path}" + return path.rstrip("/") or default + + def _opencode_path(skills_gateway_config: dict | None) -> str: if not isinstance(skills_gateway_config, dict): return OPENCODE_SKILLS_DEFAULT_PATH opencode_config = skills_gateway_config.get("opencode", {}) if not isinstance(opencode_config, dict): return OPENCODE_SKILLS_DEFAULT_PATH - path = opencode_config.get("path") or OPENCODE_SKILLS_DEFAULT_PATH - path = str(path).strip() or OPENCODE_SKILLS_DEFAULT_PATH - if not path.startswith("/"): - path = f"/{path}" - return path.rstrip("/") or OPENCODE_SKILLS_DEFAULT_PATH + return _normalized_route_path( + opencode_config.get("path"), + OPENCODE_SKILLS_DEFAULT_PATH, + ) + + +def _opencode_remote_config_path(skills_gateway_config: dict | None) -> str: + if not isinstance(skills_gateway_config, dict): + return OPENCODE_REMOTE_CONFIG_DEFAULT_PATH + opencode_config = skills_gateway_config.get("opencode", {}) + if not isinstance(opencode_config, dict): + return OPENCODE_REMOTE_CONFIG_DEFAULT_PATH + remote_config = opencode_config.get("remote_config", {}) + if not isinstance(remote_config, dict): + return OPENCODE_REMOTE_CONFIG_DEFAULT_PATH + return _normalized_route_path( + remote_config.get("path"), + OPENCODE_REMOTE_CONFIG_DEFAULT_PATH, + ) def _skill_enabled(skill: LiteLLM_SkillsTable) -> bool: @@ -130,6 +161,24 @@ async def opencode_skills_index( } +def _opencode_remote_config_response( + request: Request, + skills_gateway_config: dict | None, +) -> dict: + from litellm.proxy.utils import get_custom_url + + skills_url = get_custom_url( + request_base_url=str(request.base_url), + route=_opencode_path(skills_gateway_config), + ) + return { + "config": { + "$schema": OPENCODE_CONFIG_SCHEMA, + "skills": {"urls": [skills_url]}, + } + } + + async def opencode_skill_file( skill_name: str, file_path: str, @@ -210,6 +259,26 @@ def initialize_opencode_skills_endpoint( _add_route(app, f"{path}/{{skill_name}}/{{file_path:path}}", opencode_skill_file) +def initialize_opencode_remote_config_endpoint( + app: FastAPI, + skills_gateway_config: dict | None, +) -> None: + if not _opencode_remote_config_enabled(skills_gateway_config): + return + + async def opencode_remote_config(request: Request): + return _opencode_remote_config_response( + request=request, + skills_gateway_config=skills_gateway_config, + ) + + _add_route( + app, + _opencode_remote_config_path(skills_gateway_config), + opencode_remote_config, + ) + + def initialize_agent_skills_endpoint( app: FastAPI, skills_gateway_config: dict | None, diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index f2ca8b30523..a963a12aa03 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -4553,6 +4553,7 @@ class ProxyConfig: general_settings = {} from litellm.proxy.opencode_endpoints.skills_endpoints import ( initialize_agent_skills_endpoint, + initialize_opencode_remote_config_endpoint, initialize_opencode_skills_endpoint, ) @@ -4560,6 +4561,10 @@ class ProxyConfig: app=app, skills_gateway_config=config.get("skills_gateway"), ) + initialize_opencode_remote_config_endpoint( + app=app, + skills_gateway_config=config.get("skills_gateway"), + ) initialize_agent_skills_endpoint( app=app, skills_gateway_config=config.get("skills_gateway"), diff --git a/tests/test_litellm/proxy/test_opencode_skills_endpoints.py b/tests/test_litellm/proxy/test_opencode_skills_endpoints.py index 934e52d1e3a..2ef75d48aba 100644 --- a/tests/test_litellm/proxy/test_opencode_skills_endpoints.py +++ b/tests/test_litellm/proxy/test_opencode_skills_endpoints.py @@ -13,6 +13,7 @@ from litellm.proxy.auth.user_api_key_auth import user_api_key_auth def _client(config: dict, auth: UserAPIKeyAuth | None = None) -> TestClient: from litellm.proxy.opencode_endpoints.skills_endpoints import ( initialize_agent_skills_endpoint, + initialize_opencode_remote_config_endpoint, initialize_opencode_skills_endpoint, ) @@ -20,6 +21,7 @@ def _client(config: dict, auth: UserAPIKeyAuth | None = None) -> TestClient: if auth is not None: app.dependency_overrides[user_api_key_auth] = lambda: auth initialize_opencode_skills_endpoint(app=app, skills_gateway_config=config) + initialize_opencode_remote_config_endpoint(app=app, skills_gateway_config=config) initialize_agent_skills_endpoint(app=app, skills_gateway_config=config) return TestClient(app) @@ -32,6 +34,14 @@ def test_should_not_register_opencode_skills_endpoint_when_disabled(): assert response.status_code == 404 +def test_should_not_register_opencode_remote_config_when_disabled(): + client = _client({"enabled": True, "opencode": {"enabled": True}}) + + response = client.get("/.well-known/opencode") + + assert response.status_code == 404 + + def test_should_not_register_agent_skills_endpoint_when_disabled(): client = _client({"enabled": True}) @@ -87,6 +97,30 @@ def test_should_return_opencode_skills_index_when_enabled(monkeypatch): list_skills.assert_awaited_once_with(limit=1000, offset=0, user_api_key_dict=auth) +def test_should_return_opencode_remote_config_when_enabled_without_auth(): + client = _client( + { + "enabled": True, + "opencode": { + "enabled": True, + "remote_config": {"enabled": True}, + }, + } + ) + + response = client.get("/.well-known/opencode") + + assert response.status_code == 200 + assert response.json() == { + "config": { + "$schema": "https://opencode.ai/config.json", + "skills": { + "urls": ["http://testserver/opencode/skills"], + }, + }, + } + + def test_should_normalize_opencode_path_without_leading_slash(monkeypatch): from litellm.llms.litellm_proxy.skills.handler import LiteLLMSkillsHandler @@ -106,6 +140,32 @@ def test_should_normalize_opencode_path_without_leading_slash(monkeypatch): assert response.json() == {"skills": []} +def test_should_return_opencode_remote_config_with_custom_paths_and_proxy_base_url( + monkeypatch, +): + monkeypatch.setenv("PROXY_BASE_URL", "https://litellm.example.com/root") + client = _client( + { + "enabled": True, + "opencode": { + "enabled": True, + "path": "native/skills", + "remote_config": { + "enabled": True, + "path": "/custom/opencode", + }, + }, + } + ) + + response = client.get("/custom/opencode") + + assert response.status_code == 200 + assert response.json()["config"]["skills"]["urls"] == [ + "https://litellm.example.com/root/native/skills" + ] + + def test_should_return_agent_skills_well_known_index_when_enabled(monkeypatch): from litellm.llms.litellm_proxy.skills.handler import LiteLLMSkillsHandler @@ -200,6 +260,25 @@ def test_should_return_opencode_404_when_skill_file_is_missing(monkeypatch): assert response.json() == {"detail": "Skill file not found"} +def test_should_return_opencode_404_when_no_skills_exist(monkeypatch): + from litellm.llms.litellm_proxy.skills.handler import LiteLLMSkillsHandler + + monkeypatch.setattr( + LiteLLMSkillsHandler, + "list_skills", + AsyncMock(return_value=[]), + ) + client = _client( + {"enabled": True, "opencode": {"enabled": True}}, + auth=UserAPIKeyAuth(user_id="user-1"), + ) + + response = client.get("/opencode/skills/missing_skill/SKILL.md") + + assert response.status_code == 404 + assert response.json() == {"detail": "Skill file not found"} + + def test_should_return_agent_404_when_skill_file_is_missing(monkeypatch): from litellm.llms.litellm_proxy.skills.handler import LiteLLMSkillsHandler @@ -224,21 +303,47 @@ def test_should_return_agent_404_when_skill_file_is_missing(monkeypatch): assert response.json() == {"detail": "Skill file not found"} +def test_should_return_agent_404_when_no_skills_exist(monkeypatch): + from litellm.llms.litellm_proxy.skills.handler import LiteLLMSkillsHandler + + monkeypatch.setattr( + LiteLLMSkillsHandler, + "list_skills", + AsyncMock(return_value=[]), + ) + client = _client( + {"enabled": True, "agent_skills": {"enabled": True}}, + auth=UserAPIKeyAuth(user_id="user-1"), + ) + + response = client.get("/.well-known/agent-skills/missing-skill/SKILL.md") + + assert response.status_code == 404 + assert response.json() == {"detail": "Skill file not found"} + + def test_should_not_duplicate_routes_when_initialized_twice(): from litellm.proxy.opencode_endpoints.skills_endpoints import ( + initialize_opencode_remote_config_endpoint, initialize_opencode_skills_endpoint, ) app = FastAPI() - config = {"enabled": True, "opencode": {"enabled": True}} + config = { + "enabled": True, + "opencode": {"enabled": True, "remote_config": {"enabled": True}}, + } initialize_opencode_skills_endpoint(app=app, skills_gateway_config=config) + initialize_opencode_remote_config_endpoint(app=app, skills_gateway_config=config) initialize_opencode_skills_endpoint(app=app, skills_gateway_config=config) + initialize_opencode_remote_config_endpoint(app=app, skills_gateway_config=config) route_paths = [getattr(route, "path", None) for route in app.routes] assert route_paths.count("/opencode/skills") == 1 assert route_paths.count("/opencode/skills/index.json") == 1 assert route_paths.count("/opencode/skills/{skill_name}/{file_path:path}") == 1 + assert route_paths.count("/.well-known/opencode") == 1 def test_should_serve_legacy_agent_skills_well_known_alias(monkeypatch): @@ -415,6 +520,8 @@ async def test_should_register_skills_endpoints_from_proxy_config_load( " opencode:\n" " enabled: true\n" " path: /native/skills\n" + " remote_config:\n" + " enabled: true\n" " agent_skills:\n" " enabled: true\n" ) @@ -430,4 +537,5 @@ async def test_should_register_skills_endpoints_from_proxy_config_load( route_paths = {getattr(route, "path", None) for route in app.routes} assert "/native/skills" in route_paths assert "/native/skills/index.json" in route_paths + assert "/.well-known/opencode" in route_paths assert "/.well-known/agent-skills/index.json" in route_paths