closecode 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
closecode/auth.py ADDED
@@ -0,0 +1,22 @@
1
+ """Close Code authentication — API key management."""
2
+
3
+ from closecode.config import CloseCodeConfig, CLOSECODE_DIR
4
+
5
+
6
+ def mask_key(key: str) -> str:
7
+ """Mask an API key for display (show first 4 + last 4)."""
8
+ if len(key) <= 10:
9
+ return "*" * len(key)
10
+ return key[:4] + "*" * (len(key) - 8) + key[-4:]
11
+
12
+
13
+ def store_api_key(config: CloseCodeConfig, key: str):
14
+ """Store the API key in config."""
15
+ config.api_key = key
16
+ config.save()
17
+
18
+
19
+ def clear_credentials(config: CloseCodeConfig):
20
+ """Remove stored credentials (logout)."""
21
+ config.api_key = ""
22
+ config.save()
closecode/config.py ADDED
@@ -0,0 +1,97 @@
1
+ """Close Code configuration management.
2
+
3
+ Stores config at ~/.closecode/config.json.
4
+ Supports environment variable overrides.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ from pydantic import BaseModel, Field
13
+
14
+
15
+ # ── Paths ───────────────────────────────────────────────────────────────
16
+
17
+ CLOSECODE_DIR = Path.home() / ".closecode"
18
+ CONFIG_FILE = CLOSECODE_DIR / "config.json"
19
+ SESSIONS_DIR = CLOSECODE_DIR / "sessions"
20
+ LOGS_DIR = CLOSECODE_DIR / "logs"
21
+
22
+ # Pre-rename location; migrated once on first run.
23
+ _LEGACY_DIR = Path.home() / ".ovo"
24
+
25
+
26
+ def _migrate_legacy_dir() -> None:
27
+ """Move ~/.ovo to ~/.closecode once, so upgrades keep their sessions."""
28
+ if CLOSECODE_DIR.exists() or not _LEGACY_DIR.is_dir():
29
+ return
30
+ try:
31
+ _LEGACY_DIR.rename(CLOSECODE_DIR)
32
+ except OSError:
33
+ pass
34
+
35
+
36
+ class CloseCodeConfig(BaseModel):
37
+ """Application configuration."""
38
+
39
+ # LLMesh connection
40
+ api_url: str = Field(default="http://localhost:8087")
41
+ api_key: str = Field(default="")
42
+
43
+ # Defaults
44
+ default_mode: str = Field(default="build")
45
+ default_model: str = Field(default="")
46
+
47
+ # Current state (persisted across restarts)
48
+ current_mode: str = Field(default="build")
49
+ current_model: str = Field(default="")
50
+ current_provider: str = Field(default="")
51
+
52
+ # Behaviour
53
+ stream: bool = Field(default=True)
54
+ auto_fallback: bool = Field(default=True)
55
+ auto_compact: bool = Field(default=True)
56
+ show_usage: bool = Field(default=True)
57
+
58
+ # Context budget
59
+ system_budget: int = Field(default=10_000)
60
+ output_budget: int = Field(default=10_000)
61
+
62
+ # Tool approval
63
+ tool_approval_policy: str = Field(default="ask") # ask | allow_safe | allow_all
64
+
65
+ model_config = {"protected_namespaces": ()}
66
+
67
+ @classmethod
68
+ def load(cls) -> "CloseCodeConfig":
69
+ """Load config from disk, with env var overrides."""
70
+ _migrate_legacy_dir()
71
+ config = cls()
72
+
73
+ if CONFIG_FILE.exists():
74
+ try:
75
+ with open(CONFIG_FILE) as f:
76
+ data = json.load(f)
77
+ config = cls(**data)
78
+ except (json.JSONDecodeError, Exception):
79
+ pass
80
+
81
+ # Environment variable overrides
82
+ if url := os.environ.get("CLOSECODE_API_URL") or os.environ.get("LLMESH_API_URL"):
83
+ config.api_url = url
84
+ if key := os.environ.get("CLOSECODE_API_KEY") or os.environ.get("LLMESH_API_KEY"):
85
+ config.api_key = key
86
+
87
+ return config
88
+
89
+ def save(self):
90
+ """Persist config to disk."""
91
+ CLOSECODE_DIR.mkdir(parents=True, exist_ok=True)
92
+ with open(CONFIG_FILE, "w") as f:
93
+ json.dump(self.model_dump(), f, indent=2)
94
+
95
+ def is_configured(self) -> bool:
96
+ """Whether Close Code has a stored API key."""
97
+ return bool(self.api_key)
@@ -0,0 +1,91 @@
1
+ """Pydantic domain models for Close Code."""
2
+
3
+ from enum import Enum
4
+ from pydantic import BaseModel, Field
5
+ from typing import List
6
+
7
+
8
+ class ModelStatus(Enum):
9
+ """Status of a model on the server."""
10
+ ACTIVE = "active"
11
+ INACTIVE = "inactive"
12
+ UNAVAILABLE = "unavailable"
13
+ RATE_LIMITED = "rate_limited"
14
+
15
+ @property
16
+ def icon(self) -> str:
17
+ return {
18
+ ModelStatus.ACTIVE: "●",
19
+ ModelStatus.INACTIVE: "○",
20
+ ModelStatus.UNAVAILABLE: "✗",
21
+ ModelStatus.RATE_LIMITED: "⚠",
22
+ }[self]
23
+
24
+ @property
25
+ def label(self) -> str:
26
+ return {
27
+ ModelStatus.ACTIVE: "Active",
28
+ ModelStatus.INACTIVE: "Inactive",
29
+ ModelStatus.UNAVAILABLE: "Unavailable",
30
+ ModelStatus.RATE_LIMITED: "Rate Limited",
31
+ }[self]
32
+
33
+ @property
34
+ def selectable(self) -> bool:
35
+ """Whether this model can be selected by the user."""
36
+ return self == ModelStatus.ACTIVE
37
+
38
+
39
+ class ModelInfo(BaseModel):
40
+ """Information about an available LLM model."""
41
+ name: str = Field(description="Frontend display name")
42
+ backend_name: str = Field(default="")
43
+ provider: str = Field(default="")
44
+ server_url: str = Field(default="")
45
+ modes: List[str] = Field(default_factory=list)
46
+ capabilities: List[str] = Field(default_factory=list)
47
+ context_window: int = Field(default=0)
48
+ tool_support: bool = Field(default=False)
49
+ vision_support: bool = Field(default=False)
50
+ reasoning_support: bool = Field(default=False)
51
+ streaming_support: bool = Field(default=True)
52
+ status: bool = Field(default=True)
53
+ priority: int = Field(default=0)
54
+ weight: float = Field(default=1.0)
55
+
56
+ model_config = {"protected_namespaces": ()}
57
+
58
+ @property
59
+ def model_status(self) -> ModelStatus:
60
+ """Derive ModelStatus from the boolean status field."""
61
+ return ModelStatus.ACTIVE if self.status else ModelStatus.INACTIVE
62
+
63
+ @property
64
+ def display_name(self) -> str:
65
+ """Short display name — strip provider prefix for readability."""
66
+ name = self.name
67
+ # e.g. "openai/gpt-oss-20b:free" → "gpt-oss-20b (free)"
68
+ if "/" in name:
69
+ name = name.split("/", 1)[1]
70
+ if ":free" in name:
71
+ name = name.replace(":free", " (free)")
72
+ return name
73
+
74
+
75
+ class ProviderInfo(BaseModel):
76
+ """Provider status."""
77
+ name: str
78
+ server_url: str = ""
79
+ healthy: bool = True
80
+ models: List[str] = Field(default_factory=list)
81
+
82
+
83
+ class UsageInfo(BaseModel):
84
+ """Token usage for a request."""
85
+ input_tokens: int = 0
86
+ output_tokens: int = 0
87
+ total_tokens: int = 0
88
+ model: str = ""
89
+ latency_ms: float = 0
90
+
91
+ model_config = {"protected_namespaces": ()}
@@ -0,0 +1,221 @@
1
+ """LLMesh API client for Close Code.
2
+
3
+ Talks only to the LLMesh gateway. Never contacts upstream providers directly.
4
+ All provider authentication is handled server-side.
5
+ """
6
+
7
+ import json
8
+ import time
9
+ from typing import Optional, Dict, List, Any, AsyncIterator
10
+
11
+ import httpx
12
+
13
+ from closecode.llmesh import ModelInfo, ProviderInfo, UsageInfo
14
+ from closecode.llmesh.streaming import parse_sse_stream, StreamError
15
+
16
+
17
+ class APIError(Exception):
18
+ """Raised when the LLMesh API returns an error."""
19
+ def __init__(self, message: str, status_code: int = 0):
20
+ self.message = message
21
+ self.status_code = status_code
22
+ super().__init__(message)
23
+
24
+
25
+ class LLMeshClient:
26
+ """Async HTTP client for the LLMesh API."""
27
+
28
+ def __init__(self, base_url: str = "http://localhost:8087", api_key: str = ""):
29
+ self.base_url = base_url.rstrip("/")
30
+ self.api_key = api_key
31
+ self._client: Optional[httpx.AsyncClient] = None
32
+
33
+ def _get_headers(self) -> Dict[str, str]:
34
+ headers = {"Content-Type": "application/json"}
35
+ if self.api_key:
36
+ headers["Authorization"] = f"Bearer {self.api_key}"
37
+ return headers
38
+
39
+ async def _ensure_client(self) -> httpx.AsyncClient:
40
+ if self._client is None or self._client.is_closed:
41
+ self._client = httpx.AsyncClient(
42
+ timeout=httpx.Timeout(connect=10.0, read=300.0, write=10.0, pool=10.0),
43
+ headers=self._get_headers(),
44
+ )
45
+ return self._client
46
+
47
+ async def close(self):
48
+ if self._client and not self._client.is_closed:
49
+ await self._client.aclose()
50
+
51
+ # ── Health ──────────────────────────────────────────────────────
52
+
53
+ async def verify_connection(self) -> bool:
54
+ """Verify the LLMesh gateway is reachable and the API key is valid."""
55
+ client = await self._ensure_client()
56
+ try:
57
+ resp = await client.get(f"{self.base_url}/v1/models", timeout=5.0)
58
+ return resp.status_code == 200
59
+ except httpx.HTTPError:
60
+ return False
61
+
62
+ async def health_check(self) -> bool:
63
+ """Quick health check."""
64
+ return await self.verify_connection()
65
+
66
+ # ── Models ──────────────────────────────────────────────────────
67
+
68
+ async def list_models(self) -> List[ModelInfo]:
69
+ """Fetch models from LLMesh.
70
+
71
+ Prefers /api/cli/models, which returns every configured model with its
72
+ real admin-set status and full metadata — including models the admin has
73
+ disabled, so they can be shown as unavailable rather than vanishing.
74
+
75
+ Falls back to the OpenAI-compatible /v1/models, which by design lists
76
+ only active models, for gateways that predate the CLI endpoint.
77
+ """
78
+ client = await self._ensure_client()
79
+
80
+ # Preferred: rich metadata, includes disabled models.
81
+ try:
82
+ resp = await client.get(f"{self.base_url}/api/cli/models")
83
+ if resp.status_code == 200:
84
+ data = resp.json()
85
+ models = [
86
+ ModelInfo(
87
+ name=m.get("name", ""),
88
+ backend_name=m.get("backend_name", "") or m.get("name", ""),
89
+ provider=m.get("provider", ""),
90
+ server_url=m.get("server_url", ""),
91
+ modes=m.get("modes") or [],
92
+ capabilities=m.get("capabilities") or [],
93
+ context_window=m.get("context_window") or 0,
94
+ tool_support=bool(m.get("tool_support", False)),
95
+ vision_support=bool(m.get("vision_support", False)),
96
+ reasoning_support=bool(m.get("reasoning_support", False)),
97
+ streaming_support=bool(m.get("streaming_support", True)),
98
+ status=bool(m.get("status", True)),
99
+ priority=m.get("priority") or 0,
100
+ weight=m.get("weight") or 1.0,
101
+ )
102
+ for m in data.get("models", [])
103
+ if m.get("name")
104
+ ]
105
+ if models:
106
+ return models
107
+ except (httpx.HTTPError, json.JSONDecodeError, ValueError):
108
+ pass
109
+
110
+ # Fallback: OpenAI-compatible endpoint (active models only).
111
+ try:
112
+ resp = await client.get(f"{self.base_url}/v1/models")
113
+ if resp.status_code == 200:
114
+ data = resp.json()
115
+ models = []
116
+ for m in data.get("data", []):
117
+ model_id = m.get("id", "")
118
+ provider = m.get("owned_by", "")
119
+ models.append(ModelInfo(
120
+ name=model_id,
121
+ backend_name=model_id,
122
+ provider=provider,
123
+ status=True, # This endpoint only returns active models
124
+ ))
125
+ return models
126
+ except (httpx.HTTPError, json.JSONDecodeError):
127
+ pass
128
+
129
+ return []
130
+
131
+ async def list_providers(self) -> List[ProviderInfo]:
132
+ """Fetch provider info."""
133
+ client = await self._ensure_client()
134
+ try:
135
+ resp = await client.get(f"{self.base_url}/api/cli/providers")
136
+ if resp.status_code == 200:
137
+ data = resp.json()
138
+ return [ProviderInfo(**p) for p in data.get("providers", [])]
139
+ except (httpx.HTTPError, json.JSONDecodeError):
140
+ pass
141
+ return []
142
+
143
+ # ── Chat ────────────────────────────────────────────────────────
144
+
145
+ async def chat_stream(
146
+ self,
147
+ messages: List[Dict[str, str]],
148
+ model: str,
149
+ temperature: float = 0.7,
150
+ max_tokens: Optional[int] = None,
151
+ ) -> AsyncIterator[str]:
152
+ """Send a streaming chat completion request. Yields content tokens."""
153
+ client = await self._ensure_client()
154
+ payload: Dict[str, Any] = {
155
+ "model": model,
156
+ "messages": messages,
157
+ "stream": True,
158
+ "stream_options": {"include_usage": True},
159
+ "temperature": temperature,
160
+ }
161
+ if max_tokens:
162
+ payload["max_tokens"] = max_tokens
163
+
164
+ async with client.stream(
165
+ "POST",
166
+ f"{self.base_url}/v1/chat/completions",
167
+ json=payload,
168
+ timeout=httpx.Timeout(connect=10.0, read=300.0, write=10.0, pool=10.0),
169
+ ) as response:
170
+ if response.status_code != 200:
171
+ body = await response.aread()
172
+ raise APIError(
173
+ f"Server returned {response.status_code}: {body.decode()}",
174
+ response.status_code,
175
+ )
176
+ async for token in parse_sse_stream(response):
177
+ yield token
178
+
179
+ async def chat(
180
+ self,
181
+ messages: List[Dict[str, str]],
182
+ model: str,
183
+ temperature: float = 0.7,
184
+ max_tokens: Optional[int] = None,
185
+ ) -> Dict[str, Any]:
186
+ """Send a non-streaming chat completion. Returns full response."""
187
+ client = await self._ensure_client()
188
+ payload: Dict[str, Any] = {
189
+ "model": model,
190
+ "messages": messages,
191
+ "stream": False,
192
+ "temperature": temperature,
193
+ }
194
+ if max_tokens:
195
+ payload["max_tokens"] = max_tokens
196
+
197
+ start = time.time()
198
+ resp = await client.post(f"{self.base_url}/v1/chat/completions", json=payload)
199
+
200
+ if resp.status_code != 200:
201
+ raise APIError(f"Server returned {resp.status_code}: {resp.text}", resp.status_code)
202
+
203
+ data = resp.json()
204
+ latency_ms = (time.time() - start) * 1000
205
+ usage = data.get("usage", {})
206
+
207
+ content = ""
208
+ choices = data.get("choices", [])
209
+ if choices:
210
+ content = choices[0].get("message", {}).get("content", "")
211
+
212
+ return {
213
+ "content": content,
214
+ "usage": UsageInfo(
215
+ input_tokens=usage.get("prompt_tokens", 0),
216
+ output_tokens=usage.get("completion_tokens", 0),
217
+ total_tokens=usage.get("total_tokens", 0),
218
+ model=model,
219
+ latency_ms=latency_ms,
220
+ ),
221
+ }
@@ -0,0 +1,47 @@
1
+ """SSE stream parser for OpenAI-compatible streaming responses."""
2
+
3
+ import json
4
+ from typing import AsyncIterator
5
+
6
+ import httpx
7
+
8
+
9
+ async def parse_sse_stream(response: httpx.Response) -> AsyncIterator[str]:
10
+ """Parse an SSE stream and yield content tokens.
11
+
12
+ Handles the standard OpenAI streaming format:
13
+ data: {"choices": [{"delta": {"content": "token"}}]}
14
+ data: [DONE]
15
+ """
16
+ async for line in response.aiter_lines():
17
+ if not line.startswith("data: "):
18
+ continue
19
+
20
+ payload = line[6:].strip()
21
+
22
+ if payload == "[DONE]":
23
+ break
24
+
25
+ try:
26
+ chunk = json.loads(payload)
27
+ except json.JSONDecodeError:
28
+ continue
29
+
30
+ # Check for error responses
31
+ if "error" in chunk:
32
+ error_msg = chunk["error"].get("message", "Unknown error")
33
+ raise StreamError(error_msg)
34
+
35
+ choices = chunk.get("choices", [])
36
+ if not choices:
37
+ continue
38
+
39
+ delta = choices[0].get("delta", {})
40
+ content = delta.get("content", "")
41
+ if content:
42
+ yield content
43
+
44
+
45
+ class StreamError(Exception):
46
+ """Raised when the SSE stream contains an error."""
47
+ pass
closecode/py.typed ADDED
File without changes
closecode/sessions.py ADDED
@@ -0,0 +1,176 @@
1
+ """Close Code Session Manager — local-only session persistence.
2
+
3
+ Sessions are stored as JSON files in ~/.closecode/sessions/.
4
+ Nothing is sent to the server. Only conversation content and model name are persisted.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import uuid
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import List, Optional
14
+ from dataclasses import dataclass, field, asdict
15
+
16
+ from closecode.config import SESSIONS_DIR
17
+
18
+
19
+ @dataclass
20
+ class SessionMessage:
21
+ """A single message in a session."""
22
+ role: str
23
+ content: str
24
+ timestamp: str = ""
25
+ model: str = ""
26
+
27
+ def __post_init__(self):
28
+ if not self.timestamp:
29
+ self.timestamp = datetime.now(timezone.utc).isoformat()
30
+
31
+
32
+ @dataclass
33
+ class Session:
34
+ """A saved conversation session."""
35
+ id: str = ""
36
+ title: str = "New session"
37
+ model: str = ""
38
+ created_at: str = ""
39
+ updated_at: str = ""
40
+ messages: List[SessionMessage] = field(default_factory=list)
41
+
42
+ def __post_init__(self):
43
+ if not self.id:
44
+ self.id = uuid.uuid4().hex[:12]
45
+ now = datetime.now(timezone.utc).isoformat()
46
+ if not self.created_at:
47
+ self.created_at = now
48
+ if not self.updated_at:
49
+ self.updated_at = now
50
+
51
+ @property
52
+ def message_count(self) -> int:
53
+ return len(self.messages)
54
+
55
+ @property
56
+ def preview(self) -> str:
57
+ """First user message as a preview, truncated."""
58
+ for msg in self.messages:
59
+ if msg.role == "user":
60
+ text = msg.content.strip().replace("\n", " ")
61
+ return text[:60] + ("…" if len(text) > 60 else "")
62
+ return "Empty session"
63
+
64
+ @property
65
+ def age_label(self) -> str:
66
+ """Human-readable age like '2 hours ago'."""
67
+ try:
68
+ updated = datetime.fromisoformat(self.updated_at)
69
+ now = datetime.now(timezone.utc)
70
+ delta = now - updated
71
+ seconds = delta.total_seconds()
72
+
73
+ if seconds < 60:
74
+ return "just now"
75
+ elif seconds < 3600:
76
+ mins = int(seconds // 60)
77
+ return f"{mins}m ago"
78
+ elif seconds < 86400:
79
+ hours = int(seconds // 3600)
80
+ return f"{hours}h ago"
81
+ elif seconds < 604800:
82
+ days = int(seconds // 86400)
83
+ return f"{days}d ago"
84
+ else:
85
+ return updated.strftime("%b %d")
86
+ except Exception:
87
+ return ""
88
+
89
+ def to_dict(self) -> dict:
90
+ return {
91
+ "id": self.id,
92
+ "title": self.title,
93
+ "model": self.model,
94
+ "created_at": self.created_at,
95
+ "updated_at": self.updated_at,
96
+ "messages": [asdict(m) for m in self.messages],
97
+ }
98
+
99
+ @classmethod
100
+ def from_dict(cls, data: dict) -> Session:
101
+ msgs = [SessionMessage(**m) for m in data.get("messages", [])]
102
+ return cls(
103
+ id=data.get("id", ""),
104
+ title=data.get("title", "New session"),
105
+ model=data.get("model", ""),
106
+ created_at=data.get("created_at", ""),
107
+ updated_at=data.get("updated_at", ""),
108
+ messages=msgs,
109
+ )
110
+
111
+
112
+ class SessionManager:
113
+ """Manages local session files in ~/.closecode/sessions/."""
114
+
115
+ def __init__(self, sessions_dir: Optional[Path] = None):
116
+ self.sessions_dir = sessions_dir or SESSIONS_DIR
117
+ self.sessions_dir.mkdir(parents=True, exist_ok=True)
118
+
119
+ def _path(self, session_id: str) -> Path:
120
+ return self.sessions_dir / f"{session_id}.json"
121
+
122
+ def create(self, model: str = "", title: str = "") -> Session:
123
+ """Create a new session."""
124
+ return Session(model=model, title=title or "New session")
125
+
126
+ def save(self, session: Session) -> None:
127
+ """Save a session to disk."""
128
+ session.updated_at = datetime.now(timezone.utc).isoformat()
129
+ path = self._path(session.id)
130
+ with open(path, "w") as f:
131
+ json.dump(session.to_dict(), f, indent=2)
132
+
133
+ def load(self, session_id: str) -> Optional[Session]:
134
+ """Load a session from disk."""
135
+ path = self._path(session_id)
136
+ if not path.exists():
137
+ return None
138
+ try:
139
+ with open(path) as f:
140
+ data = json.load(f)
141
+ return Session.from_dict(data)
142
+ except (json.JSONDecodeError, Exception):
143
+ return None
144
+
145
+ def list_sessions(self, limit: int = 20) -> List[Session]:
146
+ """List all sessions, sorted by most recently updated."""
147
+ sessions = []
148
+ for path in self.sessions_dir.glob("*.json"):
149
+ try:
150
+ with open(path) as f:
151
+ data = json.load(f)
152
+ sessions.append(Session.from_dict(data))
153
+ except (json.JSONDecodeError, Exception):
154
+ continue
155
+
156
+ sessions.sort(key=lambda s: s.updated_at, reverse=True)
157
+ return sessions[:limit]
158
+
159
+ def delete(self, session_id: str) -> bool:
160
+ """Delete a session file."""
161
+ path = self._path(session_id)
162
+ if path.exists():
163
+ path.unlink()
164
+ return True
165
+ return False
166
+
167
+ @staticmethod
168
+ def auto_title(messages: List[SessionMessage]) -> str:
169
+ """Generate a title from the first user message."""
170
+ for msg in messages:
171
+ if msg.role == "user":
172
+ text = msg.content.strip().replace("\n", " ")
173
+ if len(text) > 50:
174
+ return text[:47] + "…"
175
+ return text
176
+ return "New session"
@@ -0,0 +1 @@
1
+ """Close Code UI package."""
@@ -0,0 +1 @@
1
+ """Close Code UI screens package."""