Skip to content

Commit 0d5796d

Browse files
lukehindsyrobla
authored andcommitted
Cline Support
This should be considered experimental until tester more widely by the community. I have it working with Anthropic and Ollama so far.
1 parent 3b379bb commit 0d5796d

File tree

6 files changed

+136
-13
lines changed

6 files changed

+136
-13
lines changed

src/codegate/pipeline/secrets/signatures.py

+14-4
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import re
33
from pathlib import Path
44
from threading import Lock
5-
from typing import ClassVar, Dict, List, NamedTuple, Optional
5+
from typing import ClassVar, Dict, List, NamedTuple, Optional, Union
66

77
import structlog
88
import yaml
@@ -215,16 +215,26 @@ def _load_signatures(cls) -> None:
215215
raise
216216

217217
@classmethod
218-
def find_in_string(cls, text: str) -> List[Match]:
219-
"""Search for secrets in the provided string."""
218+
def find_in_string(cls, text: Union[str, List[str]]) -> List[Match]:
219+
"""Search for secrets in the provided string or list of strings."""
220220
if not text:
221221
return []
222222

223223
if not cls._yaml_path:
224224
raise RuntimeError("SecretFinder not initialized.")
225225

226+
# Convert list to string if necessary (needed for Cline, which sends a list of strings)
227+
if isinstance(text, list):
228+
text = "\n".join(str(line) for line in text)
229+
226230
matches = []
227-
lines = text.splitlines()
231+
232+
# Split text into lines for processing
233+
try:
234+
lines = text.splitlines()
235+
except Exception as e:
236+
logger.warning(f"Error splitting text into lines: {e}")
237+
return []
228238

229239
for line_num, line in enumerate(lines, start=1):
230240
for group in cls._signature_groups:

src/codegate/pipeline/system_prompt/codegate.py

+10-2
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,20 @@ async def process(
5151
# Add system message
5252
context.add_alert(self.name, trigger_string=json.dumps(self._system_message))
5353
new_request["messages"].insert(0, self._system_message)
54-
elif "codegate" not in request_system_message["content"].lower():
54+
# Addded Logic for Cline, which sends a list of strings
55+
elif (
56+
"content" not in request_system_message
57+
or not isinstance(request_system_message["content"], str)
58+
or "codegate" not in request_system_message["content"].lower()
59+
):
5560
# Prepend to the system message
61+
original_content = request_system_message.get("content", "")
62+
if not isinstance(original_content, str):
63+
original_content = json.dumps(original_content)
5664
prepended_message = (
5765
self._system_message["content"]
5866
+ "\n Here are additional instructions. \n "
59-
+ request_system_message["content"]
67+
+ original_content
6068
)
6169
context.add_alert(self.name, trigger_string=prepended_message)
6270
request_system_message["content"] = prepended_message

src/codegate/pipeline/systemmsg.py

+12-1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ def get_existing_system_message(request: ChatCompletionRequest) -> Optional[dict
1616
Returns:
1717
The existing system message if found, otherwise None.
1818
"""
19+
1920
for message in request.get("messages", []):
2021
if message["role"] == "system":
2122
return message
@@ -50,8 +51,18 @@ def add_or_update_system_message(
5051
context.add_alert("add-system-message", trigger_string=json.dumps(system_message))
5152
new_request["messages"].insert(0, system_message)
5253
else:
54+
# Handle both string and list content types (needed for Cline (sends list)
55+
existing_content = request_system_message["content"]
56+
new_content = system_message["content"]
57+
58+
# Convert list to string if necessary (needed for Cline (sends list)
59+
if isinstance(existing_content, list):
60+
existing_content = "\n".join(str(item) for item in existing_content)
61+
if isinstance(new_content, list):
62+
new_content = "\n".join(str(item) for item in new_content)
63+
5364
# Update existing system message
54-
updated_content = request_system_message["content"] + "\n\n" + system_message["content"]
65+
updated_content = existing_content + "\n\n" + new_content
5566
context.add_alert("update-system-message", trigger_string=updated_content)
5667
request_system_message["content"] = updated_content
5768

src/codegate/providers/anthropic/provider.py

+5
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,14 @@ def _setup_routes(self):
4040
Sets up the /messages route for the provider as expected by the Anthropic
4141
API. Extracts the API key from the "x-api-key" header and passes it to the
4242
completion handler.
43+
44+
There are two routes:
45+
- /messages: This is the route that is used by the Anthropic API with Continue.dev
46+
- /v1/messages: This is the route that is used by the Anthropic API with Cline
4347
"""
4448

4549
@self.router.post(f"/{self.provider_route_name}/messages")
50+
@self.router.post(f"/{self.provider_route_name}/v1/messages")
4651
async def create_message(
4752
request: Request,
4853
x_api_key: str = Header(None),

src/codegate/providers/ollama/completion_handler.py

+64-5
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
from typing import AsyncIterator, Optional, Union
23

34
import structlog
@@ -11,32 +12,90 @@
1112

1213

1314
async def ollama_stream_generator(
14-
stream: AsyncIterator[ChatResponse],
15+
stream: AsyncIterator[ChatResponse], is_cline_client: bool
1516
) -> AsyncIterator[str]:
1617
"""OpenAI-style SSE format"""
1718
try:
1819
async for chunk in stream:
1920
try:
20-
yield f"{chunk.model_dump_json()}\n\n"
21+
# TODO We should wire in the client info so we can respond with
22+
# the correct format and start to handle multiple clients
23+
# in a more robust way.
24+
if not is_cline_client:
25+
yield f"{chunk.model_dump_json()}\n\n"
26+
else:
27+
# First get the raw dict from the chunk
28+
chunk_dict = chunk.model_dump()
29+
# Create response dictionary in OpenAI-like format
30+
response = {
31+
"id": f"chatcmpl-{chunk_dict.get('created_at', '')}",
32+
"object": "chat.completion.chunk",
33+
"created": chunk_dict.get("created_at"),
34+
"model": chunk_dict.get("model"),
35+
"choices": [
36+
{
37+
"index": 0,
38+
"delta": {
39+
"content": chunk_dict.get("message", {}).get("content", ""),
40+
"role": chunk_dict.get("message", {}).get("role", "assistant"),
41+
},
42+
"finish_reason": (
43+
chunk_dict.get("done_reason")
44+
if chunk_dict.get("done", False)
45+
else None
46+
),
47+
}
48+
],
49+
}
50+
# Preserve existing type or add default if missing
51+
response["type"] = chunk_dict.get("type", "stream")
52+
53+
# Add optional fields that might be present in the final message
54+
optional_fields = [
55+
"total_duration",
56+
"load_duration",
57+
"prompt_eval_count",
58+
"prompt_eval_duration",
59+
"eval_count",
60+
"eval_duration",
61+
]
62+
for field in optional_fields:
63+
if field in chunk_dict:
64+
response[field] = chunk_dict[field]
65+
66+
yield f"data: {json.dumps(response)}\n\n"
2167
except Exception as e:
22-
yield f"{str(e)}\n\n"
68+
logger.error(f"Error in stream generator: {str(e)}")
69+
yield f"data: {json.dumps({'error': str(e), 'type': 'error', 'choices': []})}\n\n"
2370
except Exception as e:
24-
yield f"{str(e)}\n\n"
71+
logger.error(f"Stream error: {str(e)}")
72+
yield f"data: {json.dumps({'error': str(e), 'type': 'error', 'choices': []})}\n\n"
2573

2674

2775
class OllamaShim(BaseCompletionHandler):
2876

2977
def __init__(self, base_url):
3078
self.client = AsyncClient(host=base_url, timeout=300)
79+
self.is_cline_client = False
3180

3281
async def execute_completion(
3382
self,
3483
request: ChatCompletionRequest,
3584
api_key: Optional[str],
3685
stream: bool = False,
3786
is_fim_request: bool = False,
87+
is_cline_client: bool = False,
3888
) -> Union[ChatResponse, GenerateResponse]:
3989
"""Stream response directly from Ollama API."""
90+
91+
# TODO: I don't like this, but it's a quick fix for now until we start
92+
# passing through the client info so we can respond with the correct
93+
# format.
94+
# Determine if the client is a Cline client
95+
self.is_cline_client = any(
96+
"Cline" in message["content"] for message in request.get("messages", [])
97+
)
98+
4099
if is_fim_request:
41100
prompt = request["messages"][0]["content"]
42101
response = await self.client.generate(
@@ -57,7 +116,7 @@ def _create_streaming_response(self, stream: AsyncIterator[ChatResponse]) -> Str
57116
is the format that FastAPI expects for streaming responses.
58117
"""
59118
return StreamingResponse(
60-
ollama_stream_generator(stream),
119+
ollama_stream_generator(stream, self.is_cline_client),
61120
media_type="application/x-ndjson",
62121
headers={
63122
"Cache-Control": "no-cache",

src/codegate/providers/ollama/provider.py

+31-1
Original file line numberDiff line numberDiff line change
@@ -72,12 +72,42 @@ async def show_model(request: Request):
7272
)
7373
return response.json()
7474

75+
@self.router.get(f"/{self.provider_route_name}/api/tags")
76+
async def get_tags(request: Request):
77+
"""
78+
Special route for /api/tags that responds outside of the pipeline
79+
Tags are used to get the list of models
80+
https://github.com/ollama/ollama/blob/main/docs/api.md#list-local-models
81+
"""
82+
async with httpx.AsyncClient() as client:
83+
response = await client.get(f"{self.base_url}/api/tags")
84+
return response.json()
85+
86+
@self.router.post(f"/{self.provider_route_name}/api/show")
87+
async def show_model(request: Request):
88+
"""
89+
route for /api/show that responds outside of the pipeline
90+
/api/show displays model is used to get the model information
91+
https://github.com/ollama/ollama/blob/main/docs/api.md#show-model-information
92+
"""
93+
body = await request.body()
94+
async with httpx.AsyncClient() as client:
95+
response = await client.post(
96+
f"{self.base_url}/api/show",
97+
content=body,
98+
headers={"Content-Type": "application/json"},
99+
)
100+
return response.json()
101+
75102
# Native Ollama API routes
76103
@self.router.post(f"/{self.provider_route_name}/api/chat")
77104
@self.router.post(f"/{self.provider_route_name}/api/generate")
78105
# OpenAI-compatible routes for backward compatibility
79106
@self.router.post(f"/{self.provider_route_name}/chat/completions")
80107
@self.router.post(f"/{self.provider_route_name}/completions")
108+
# Cline API routes
109+
@self.router.post(f"/{self.provider_route_name}/v1/chat/completions")
110+
@self.router.post(f"/{self.provider_route_name}/v1/generate")
81111
async def create_completion(request: Request):
82112
body = await request.body()
83113
data = json.loads(body)
@@ -93,7 +123,7 @@ async def create_completion(request: Request):
93123
logger.error("Error in OllamaProvider completion", error=str(e))
94124
raise HTTPException(status_code=503, detail="Ollama service is unavailable")
95125
except Exception as e:
96-
#  check if we have an status code there
126+
# check if we have an status code there
97127
if hasattr(e, "status_code"):
98128
# log the exception
99129
logger = structlog.get_logger("codegate")

0 commit comments

Comments
 (0)