convo-cli 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.
- convo/__init__.py +3 -0
- convo/__main__.py +110 -0
- convo/bootstrap.py +273 -0
- convo/core/__init__.py +1 -0
- convo/core/config.py +53 -0
- convo/core/contracts.py +160 -0
- convo/core/exceptions.py +5 -0
- convo/db/__init__.py +1 -0
- convo/db/base.py +13 -0
- convo/db/chunks.py +132 -0
- convo/db/conversations.py +167 -0
- convo/db/documents.py +34 -0
- convo/db/models.py +81 -0
- convo/db/session.py +27 -0
- convo/llm/__init__.py +0 -0
- convo/llm/auth.py +504 -0
- convo/llm/client.py +207 -0
- convo/llm/credentials.py +192 -0
- convo/llm/models.py +71 -0
- convo/llm/streaming.py +62 -0
- convo/migrations/env.py +43 -0
- convo/migrations/script.py.mako +22 -0
- convo/migrations/versions/5c3b75f9c5c1_describe_your_schema_change.py +22 -0
- convo/migrations/versions/732cf62e465c_create_rag_tables.py +49 -0
- convo/migrations/versions/README.md +3 -0
- convo/migrations/versions/a42e9d8f0b31_hybrid_search_and_chat_sessions.py +66 -0
- convo/pipeline/__init__.py +0 -0
- convo/pipeline/chat.py +129 -0
- convo/pipeline/chunking.py +42 -0
- convo/pipeline/embeddings.py +83 -0
- convo/pipeline/index.py +160 -0
- convo/pipeline/ingestion.py +46 -0
- convo/pipeline/reranking.py +63 -0
- convo/pipeline/routing.py +64 -0
- convo/pipeline/sessions.py +40 -0
- convo/pipeline/worker.py +226 -0
- convo/prompts/__init__.py +0 -0
- convo/prompts/answers.py +97 -0
- convo/prompts/schema.py +97 -0
- convo/ui/__init__.py +1 -0
- convo/ui/app.py +752 -0
- convo/ui/commands.py +37 -0
- convo/ui/files.py +110 -0
- convo/ui/models.py +84 -0
- convo/ui/preview.py +20 -0
- convo/ui/sessions.py +97 -0
- convo/ui/theme.tcss +64 -0
- convo/ui/thinking.py +70 -0
- convo_cli-0.1.0.dist-info/METADATA +160 -0
- convo_cli-0.1.0.dist-info/RECORD +53 -0
- convo_cli-0.1.0.dist-info/WHEEL +4 -0
- convo_cli-0.1.0.dist-info/entry_points.txt +2 -0
- convo_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
convo/llm/client.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Generate evidence-grounded answers through a connected ChatGPT account."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from openai import BadRequestError
|
|
9
|
+
|
|
10
|
+
from convo.core.contracts import (
|
|
11
|
+
Answer,
|
|
12
|
+
ChatModel,
|
|
13
|
+
GenerationSelection,
|
|
14
|
+
Hit,
|
|
15
|
+
Message,
|
|
16
|
+
ReasoningEffort,
|
|
17
|
+
)
|
|
18
|
+
from convo.core.exceptions import BackendNotConnected
|
|
19
|
+
from convo.llm.auth import RESOURCE, ChatGPTAuth
|
|
20
|
+
from convo.llm.models import parse_model_catalog
|
|
21
|
+
from convo.llm.streaming import complete_chatgpt
|
|
22
|
+
from convo.prompts.answers import INSTRUCTIONS, bounded_hits, make_input
|
|
23
|
+
from convo.prompts.schema import insufficient_answer, parse_answer
|
|
24
|
+
|
|
25
|
+
MODEL_LIST_URL = f"{RESOURCE}/models"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ChatGPTProvider:
|
|
29
|
+
"""OpenAI generation through the user's connected ChatGPT account."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, auth: ChatGPTAuth) -> None:
|
|
32
|
+
self.auth = auth
|
|
33
|
+
self._models: tuple[ChatModel, ...] = ()
|
|
34
|
+
self._catalog_at = 0.0
|
|
35
|
+
self._catalog_account: tuple[str, str] | None = None
|
|
36
|
+
|
|
37
|
+
def reset_catalog(self) -> None:
|
|
38
|
+
self._models = ()
|
|
39
|
+
self._catalog_at = 0.0
|
|
40
|
+
self._catalog_account = None
|
|
41
|
+
|
|
42
|
+
def generation_selection(self) -> GenerationSelection:
|
|
43
|
+
profile = self.auth.account_profile()
|
|
44
|
+
if profile is None:
|
|
45
|
+
return GenerationSelection()
|
|
46
|
+
if profile.selected_model is None:
|
|
47
|
+
return GenerationSelection(connected=True)
|
|
48
|
+
model = next((item for item in self._models if item.id == profile.selected_model), None)
|
|
49
|
+
if model is None:
|
|
50
|
+
model = ChatModel(
|
|
51
|
+
profile.selected_model, profile.selected_model_name or profile.selected_model
|
|
52
|
+
)
|
|
53
|
+
return GenerationSelection(model, profile.selected_reasoning_effort, connected=True)
|
|
54
|
+
|
|
55
|
+
async def list_models(self) -> tuple[ChatModel, ...]:
|
|
56
|
+
await self._refresh_catalog(await self.auth.access_token())
|
|
57
|
+
return self._models
|
|
58
|
+
|
|
59
|
+
async def _refresh_catalog(self, access_token: str) -> None:
|
|
60
|
+
models = await list_account_models(access_token)
|
|
61
|
+
if not models:
|
|
62
|
+
raise BackendNotConnected("This ChatGPT account has no models available for Convo.")
|
|
63
|
+
profile = self.auth.account_profile()
|
|
64
|
+
if profile is None:
|
|
65
|
+
raise BackendNotConnected("Connect your ChatGPT account with /login")
|
|
66
|
+
model = next((item for item in models if item.id == profile.selected_model), models[0])
|
|
67
|
+
effort = (
|
|
68
|
+
profile.selected_reasoning_effort
|
|
69
|
+
if model.id == profile.selected_model
|
|
70
|
+
and profile.selected_reasoning_effort in model.reasoning_efforts
|
|
71
|
+
else None
|
|
72
|
+
)
|
|
73
|
+
if (
|
|
74
|
+
profile.selected_model,
|
|
75
|
+
profile.selected_model_name,
|
|
76
|
+
profile.selected_reasoning_effort,
|
|
77
|
+
) != (model.id, model.name, effort):
|
|
78
|
+
self.auth.save_generation_preferences(model.id, model.name, effort)
|
|
79
|
+
self._models = models
|
|
80
|
+
self._catalog_account = (profile.client_id, profile.subject)
|
|
81
|
+
self._catalog_at = time.monotonic()
|
|
82
|
+
|
|
83
|
+
async def _ensure_catalog(self, access_token: str | None = None) -> None:
|
|
84
|
+
profile = self.auth.account_profile()
|
|
85
|
+
account = (profile.client_id, profile.subject) if profile else None
|
|
86
|
+
if (
|
|
87
|
+
not self._models
|
|
88
|
+
or account != self._catalog_account
|
|
89
|
+
or time.monotonic() - self._catalog_at > 300
|
|
90
|
+
):
|
|
91
|
+
await self._refresh_catalog(access_token or await self.auth.access_token())
|
|
92
|
+
|
|
93
|
+
async def select_model(self, model_id: str) -> GenerationSelection:
|
|
94
|
+
await self._ensure_catalog()
|
|
95
|
+
model = next((item for item in self._models if item.id == model_id), None)
|
|
96
|
+
if model is None:
|
|
97
|
+
raise ValueError("That model is no longer available. Run /models to refresh the list.")
|
|
98
|
+
current = self.generation_selection()
|
|
99
|
+
effort = (
|
|
100
|
+
current.reasoning_effort
|
|
101
|
+
if current.reasoning_effort in model.reasoning_efforts
|
|
102
|
+
else None
|
|
103
|
+
)
|
|
104
|
+
self.auth.save_generation_preferences(model.id, model.name, effort)
|
|
105
|
+
return GenerationSelection(model, effort, connected=True)
|
|
106
|
+
|
|
107
|
+
async def cycle_reasoning(self) -> GenerationSelection:
|
|
108
|
+
await self._ensure_catalog()
|
|
109
|
+
current = self.generation_selection()
|
|
110
|
+
model = current.model
|
|
111
|
+
if model is None:
|
|
112
|
+
raise BackendNotConnected("Choose a ChatGPT model with /models first")
|
|
113
|
+
choices = (None, *model.reasoning_efforts)
|
|
114
|
+
index = (
|
|
115
|
+
choices.index(current.reasoning_effort) if current.reasoning_effort in choices else 0
|
|
116
|
+
)
|
|
117
|
+
effort = choices[(index + 1) % len(choices)]
|
|
118
|
+
self.auth.save_generation_preferences(model.id, model.name, effort)
|
|
119
|
+
return GenerationSelection(model, effort, connected=True)
|
|
120
|
+
|
|
121
|
+
async def select_reasoning(self, effort: ReasoningEffort | None) -> GenerationSelection:
|
|
122
|
+
await self._ensure_catalog()
|
|
123
|
+
model = self.generation_selection().model
|
|
124
|
+
if model is None:
|
|
125
|
+
raise BackendNotConnected("Choose a ChatGPT model with /models first")
|
|
126
|
+
if effort is not None and effort not in model.reasoning_efforts:
|
|
127
|
+
supported = ", ".join(("auto", *model.reasoning_efforts))
|
|
128
|
+
raise ValueError(f"{model.name} supports these thinking levels: {supported}.")
|
|
129
|
+
self.auth.save_generation_preferences(model.id, model.name, effort)
|
|
130
|
+
return GenerationSelection(model, effort, connected=True)
|
|
131
|
+
|
|
132
|
+
async def answer(
|
|
133
|
+
self,
|
|
134
|
+
filename: str,
|
|
135
|
+
question: str,
|
|
136
|
+
history: tuple[Message, ...],
|
|
137
|
+
hits: list[Hit],
|
|
138
|
+
) -> Answer:
|
|
139
|
+
excerpts = bounded_hits(hits)
|
|
140
|
+
if not excerpts:
|
|
141
|
+
return insufficient_answer()
|
|
142
|
+
access_token = await self.auth.access_token()
|
|
143
|
+
await self._ensure_catalog(access_token)
|
|
144
|
+
selection = self.generation_selection()
|
|
145
|
+
if selection.model is None:
|
|
146
|
+
raise BackendNotConnected("Choose a ChatGPT model with /models first")
|
|
147
|
+
input_items = make_input(filename, question, history, excerpts)
|
|
148
|
+
for attempt in range(2):
|
|
149
|
+
try:
|
|
150
|
+
raw = await complete_chatgpt(
|
|
151
|
+
access_token=access_token,
|
|
152
|
+
model=selection.model.id,
|
|
153
|
+
instructions=INSTRUCTIONS,
|
|
154
|
+
input_items=input_items,
|
|
155
|
+
reasoning_effort=selection.reasoning_effort,
|
|
156
|
+
structured=True,
|
|
157
|
+
)
|
|
158
|
+
except BadRequestError as error:
|
|
159
|
+
raise BackendNotConnected(
|
|
160
|
+
"The selected model could not accept the structured-answer request. "
|
|
161
|
+
"Choose another model with /models and retry."
|
|
162
|
+
) from error
|
|
163
|
+
try:
|
|
164
|
+
return parse_answer(raw, filename, excerpts)
|
|
165
|
+
except ValueError:
|
|
166
|
+
if attempt:
|
|
167
|
+
raise BackendNotConnected(
|
|
168
|
+
"ChatGPT could not produce a valid answer with supporting quotations. "
|
|
169
|
+
"Try a more specific question; no incomplete answer was saved."
|
|
170
|
+
) from None
|
|
171
|
+
input_items.extend(
|
|
172
|
+
[
|
|
173
|
+
{"role": "assistant", "content": raw[:30_000]},
|
|
174
|
+
{
|
|
175
|
+
"role": "user",
|
|
176
|
+
"content": "Your response failed evidence validation. Return the "
|
|
177
|
+
"required schema with a supporting citation for each answer section. Use only "
|
|
178
|
+
"the current source IDs and exact quotes copied from their excerpts. No inline "
|
|
179
|
+
"citation markers in text. If unsupported, use insufficient_evidence.",
|
|
180
|
+
},
|
|
181
|
+
]
|
|
182
|
+
)
|
|
183
|
+
raise RuntimeError("No validated answer")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
async def list_account_models(access_token: str) -> tuple[ChatModel, ...]:
|
|
187
|
+
"""Fetch the account's displayable model catalog in the server's order."""
|
|
188
|
+
try:
|
|
189
|
+
async with httpx.AsyncClient(timeout=20) as client:
|
|
190
|
+
response = await client.get(
|
|
191
|
+
MODEL_LIST_URL,
|
|
192
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
193
|
+
)
|
|
194
|
+
response.raise_for_status()
|
|
195
|
+
return parse_model_catalog(response.json())
|
|
196
|
+
except httpx.HTTPStatusError as error:
|
|
197
|
+
if error.response.status_code in (401, 403):
|
|
198
|
+
raise BackendNotConnected(
|
|
199
|
+
"ChatGPT model access was denied. Reconnect with /login."
|
|
200
|
+
) from error
|
|
201
|
+
raise BackendNotConnected(
|
|
202
|
+
"Could not load ChatGPT models. Retry /models shortly."
|
|
203
|
+
) from error
|
|
204
|
+
except httpx.HTTPError as error:
|
|
205
|
+
raise BackendNotConnected(
|
|
206
|
+
"Could not reach ChatGPT. Check your connection and retry /models."
|
|
207
|
+
) from error
|
convo/llm/credentials.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Secure token storage and owner-only account metadata for local sign-in."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
from dataclasses import asdict, dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Protocol
|
|
12
|
+
|
|
13
|
+
from convo.core.contracts import REASONING_EFFORTS, ReasoningEffort
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CredentialStore(Protocol):
|
|
17
|
+
"""Small seam around secure local token storage."""
|
|
18
|
+
|
|
19
|
+
def save(self, key: str, value: str) -> None: ...
|
|
20
|
+
|
|
21
|
+
def load(self, key: str) -> str | None: ...
|
|
22
|
+
|
|
23
|
+
def delete(self, key: str) -> None: ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class KeyringCredentialStore:
|
|
27
|
+
"""Store OAuth secrets in the operating system's credential manager."""
|
|
28
|
+
|
|
29
|
+
service_name = "convo"
|
|
30
|
+
|
|
31
|
+
def save(self, key: str, value: str) -> None:
|
|
32
|
+
import keyring
|
|
33
|
+
|
|
34
|
+
keyring.set_password(self.service_name, key, value)
|
|
35
|
+
|
|
36
|
+
def load(self, key: str) -> str | None:
|
|
37
|
+
import keyring
|
|
38
|
+
|
|
39
|
+
return keyring.get_password(self.service_name, key)
|
|
40
|
+
|
|
41
|
+
def delete(self, key: str) -> None:
|
|
42
|
+
import keyring
|
|
43
|
+
from keyring.errors import PasswordDeleteError
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
keyring.delete_password(self.service_name, key)
|
|
47
|
+
except PasswordDeleteError:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class AccountProfile:
|
|
53
|
+
"""Non-secret metadata for the one ChatGPT account connected to this CLI."""
|
|
54
|
+
|
|
55
|
+
client_id: str
|
|
56
|
+
subject: str
|
|
57
|
+
email: str | None
|
|
58
|
+
name: str | None
|
|
59
|
+
issuer: str
|
|
60
|
+
scopes: tuple[str, ...]
|
|
61
|
+
expires_at: float
|
|
62
|
+
selected_model: str | None = None
|
|
63
|
+
selected_model_name: str | None = None
|
|
64
|
+
selected_reasoning_effort: ReasoningEffort | None = None
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def from_dict(cls, value: dict[str, object]) -> AccountProfile:
|
|
68
|
+
scopes = value.get("scopes")
|
|
69
|
+
if not isinstance(scopes, list) or not all(isinstance(scope, str) for scope in scopes):
|
|
70
|
+
raise ValueError("Invalid scopes in saved ChatGPT profile")
|
|
71
|
+
client_id = value.get("client_id")
|
|
72
|
+
subject = value.get("subject")
|
|
73
|
+
issuer = value.get("issuer")
|
|
74
|
+
expires_at = value.get("expires_at")
|
|
75
|
+
email = value.get("email")
|
|
76
|
+
name = value.get("name")
|
|
77
|
+
selected_model = value.get("selected_model")
|
|
78
|
+
selected_model_name = value.get("selected_model_name")
|
|
79
|
+
selected_effort = value.get("selected_reasoning_effort")
|
|
80
|
+
if not isinstance(client_id, str) or not client_id:
|
|
81
|
+
raise ValueError("Invalid identity in saved ChatGPT profile")
|
|
82
|
+
if not isinstance(subject, str) or not subject:
|
|
83
|
+
raise ValueError("Invalid identity in saved ChatGPT profile")
|
|
84
|
+
if not isinstance(issuer, str) or not issuer:
|
|
85
|
+
raise ValueError("Invalid identity in saved ChatGPT profile")
|
|
86
|
+
if not isinstance(expires_at, (float, int)):
|
|
87
|
+
raise ValueError("Invalid expiry in saved ChatGPT profile")
|
|
88
|
+
if email is not None and not isinstance(email, str):
|
|
89
|
+
raise ValueError("Invalid email in saved ChatGPT profile")
|
|
90
|
+
if name is not None and not isinstance(name, str):
|
|
91
|
+
raise ValueError("Invalid name in saved ChatGPT profile")
|
|
92
|
+
if selected_model is not None and not isinstance(selected_model, str):
|
|
93
|
+
raise ValueError("Invalid selected model in saved ChatGPT profile")
|
|
94
|
+
return cls(
|
|
95
|
+
client_id=client_id,
|
|
96
|
+
subject=subject,
|
|
97
|
+
email=email,
|
|
98
|
+
name=name,
|
|
99
|
+
issuer=issuer,
|
|
100
|
+
scopes=tuple(scopes),
|
|
101
|
+
expires_at=float(expires_at),
|
|
102
|
+
selected_model=selected_model,
|
|
103
|
+
selected_model_name=selected_model_name
|
|
104
|
+
if isinstance(selected_model_name, str)
|
|
105
|
+
else None,
|
|
106
|
+
selected_reasoning_effort=(
|
|
107
|
+
selected_effort
|
|
108
|
+
if isinstance(selected_effort, str) and selected_effort in REASONING_EFFORTS
|
|
109
|
+
else None
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def default_config_dir() -> Path:
|
|
115
|
+
"""Return the per-user config directory; allow a test/developer override."""
|
|
116
|
+
override = os.environ.get("CONVO_CONFIG_HOME")
|
|
117
|
+
if override:
|
|
118
|
+
return Path(override).expanduser()
|
|
119
|
+
if sys.platform == "darwin":
|
|
120
|
+
return Path.home() / "Library" / "Application Support" / "convo"
|
|
121
|
+
if sys.platform == "win32":
|
|
122
|
+
return Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) / "convo"
|
|
123
|
+
return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "convo"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class AccountProfileStore:
|
|
127
|
+
"""Persist a stable host ID and non-secret account details outside the repo."""
|
|
128
|
+
|
|
129
|
+
def __init__(self, directory: Path | None = None) -> None:
|
|
130
|
+
self.directory = (directory or default_config_dir()).expanduser()
|
|
131
|
+
self.profile_path = self.directory / "chatgpt-profile.json"
|
|
132
|
+
self.host_id_path = self.directory / "host-id"
|
|
133
|
+
|
|
134
|
+
def _ensure_private_directory(self) -> None:
|
|
135
|
+
self.directory.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
136
|
+
if os.name != "nt":
|
|
137
|
+
self.directory.chmod(0o700)
|
|
138
|
+
|
|
139
|
+
def host_id(self) -> str:
|
|
140
|
+
self._ensure_private_directory()
|
|
141
|
+
try:
|
|
142
|
+
value = self.host_id_path.read_text(encoding="utf-8").strip()
|
|
143
|
+
except FileNotFoundError:
|
|
144
|
+
value = ""
|
|
145
|
+
if value.startswith("urn:uuid:") and len(value) == len("urn:uuid:") + 36:
|
|
146
|
+
return value
|
|
147
|
+
|
|
148
|
+
from uuid import uuid4
|
|
149
|
+
|
|
150
|
+
value = f"urn:uuid:{uuid4()}"
|
|
151
|
+
self._write_private(self.host_id_path, value.encode("utf-8"))
|
|
152
|
+
return value
|
|
153
|
+
|
|
154
|
+
def load(self) -> AccountProfile | None:
|
|
155
|
+
try:
|
|
156
|
+
raw = self.profile_path.read_text(encoding="utf-8")
|
|
157
|
+
except FileNotFoundError:
|
|
158
|
+
return None
|
|
159
|
+
try:
|
|
160
|
+
value = json.loads(raw)
|
|
161
|
+
if not isinstance(value, dict):
|
|
162
|
+
raise ValueError("Saved ChatGPT profile must be a JSON object")
|
|
163
|
+
return AccountProfile.from_dict(value)
|
|
164
|
+
except (json.JSONDecodeError, ValueError) as error:
|
|
165
|
+
raise ValueError("Saved ChatGPT profile is invalid; sign in again") from error
|
|
166
|
+
|
|
167
|
+
def save(self, profile: AccountProfile) -> None:
|
|
168
|
+
self._ensure_private_directory()
|
|
169
|
+
content = json.dumps(asdict(profile), indent=2, sort_keys=True).encode("utf-8")
|
|
170
|
+
self._write_private(self.profile_path, content)
|
|
171
|
+
|
|
172
|
+
@staticmethod
|
|
173
|
+
def _write_private(path: Path, content: bytes) -> None:
|
|
174
|
+
"""Atomically replace a file and keep it owner-readable on Unix."""
|
|
175
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
176
|
+
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
177
|
+
temporary_path = Path(temporary_name)
|
|
178
|
+
try:
|
|
179
|
+
if os.name != "nt":
|
|
180
|
+
os.fchmod(descriptor, 0o600)
|
|
181
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
182
|
+
stream.write(content)
|
|
183
|
+
stream.flush()
|
|
184
|
+
os.fsync(stream.fileno())
|
|
185
|
+
os.replace(temporary_path, path)
|
|
186
|
+
if os.name != "nt":
|
|
187
|
+
path.chmod(0o600)
|
|
188
|
+
finally:
|
|
189
|
+
try:
|
|
190
|
+
temporary_path.unlink()
|
|
191
|
+
except FileNotFoundError:
|
|
192
|
+
pass
|
convo/llm/models.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Normalize account catalogs and limit reasoning controls to known capabilities."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from convo.core.contracts import REASONING_EFFORTS, ChatModel, ReasoningEffort
|
|
6
|
+
|
|
7
|
+
# A conservative subset documented for these standard models. Catalog-provided
|
|
8
|
+
# capabilities take precedence; unfamiliar models keep the server's default.
|
|
9
|
+
_STANDARD_REASONING_MODELS = (
|
|
10
|
+
"gpt-5.5",
|
|
11
|
+
"gpt-5.6",
|
|
12
|
+
"gpt-6-astra",
|
|
13
|
+
"gpt-6-sol",
|
|
14
|
+
"gpt-6.1-sol",
|
|
15
|
+
"gpt-6-luna",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def reasoning_effort(value: object) -> ReasoningEffort | None:
|
|
20
|
+
if isinstance(value, str) and value in REASONING_EFFORTS:
|
|
21
|
+
return value
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_model_catalog(payload: object) -> tuple[ChatModel, ...]:
|
|
26
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("models"), list):
|
|
27
|
+
raise RuntimeError("ChatGPT returned an invalid model catalog")
|
|
28
|
+
models: list[ChatModel] = []
|
|
29
|
+
seen: set[str] = set()
|
|
30
|
+
for item in payload["models"]:
|
|
31
|
+
if not isinstance(item, dict) or item.get("visibility") != "list":
|
|
32
|
+
continue
|
|
33
|
+
slug = item.get("slug")
|
|
34
|
+
if not isinstance(slug, str) or not slug or slug in seen:
|
|
35
|
+
continue
|
|
36
|
+
seen.add(slug)
|
|
37
|
+
display_name = item.get("display_name")
|
|
38
|
+
capabilities = item.get(
|
|
39
|
+
"supported_reasoning_levels", item.get("supported_reasoning_efforts")
|
|
40
|
+
)
|
|
41
|
+
if isinstance(capabilities, list):
|
|
42
|
+
efforts = {
|
|
43
|
+
effort
|
|
44
|
+
for entry in capabilities
|
|
45
|
+
if (
|
|
46
|
+
effort := reasoning_effort(
|
|
47
|
+
entry.get("effort") if isinstance(entry, dict) else entry
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
is not None
|
|
51
|
+
}
|
|
52
|
+
supported = tuple(effort for effort in REASONING_EFFORTS if effort in efforts)
|
|
53
|
+
elif any(
|
|
54
|
+
re.fullmatch(re.escape(model) + r"(?:-\d{4}-\d{2}-\d{2})?", slug)
|
|
55
|
+
for model in _STANDARD_REASONING_MODELS
|
|
56
|
+
):
|
|
57
|
+
supported = ("low", "medium", "high")
|
|
58
|
+
else:
|
|
59
|
+
supported = ()
|
|
60
|
+
default = reasoning_effort(
|
|
61
|
+
item.get("default_reasoning_level", item.get("default_reasoning_effort"))
|
|
62
|
+
)
|
|
63
|
+
models.append(
|
|
64
|
+
ChatModel(
|
|
65
|
+
id=slug,
|
|
66
|
+
name=display_name if isinstance(display_name, str) and display_name else slug,
|
|
67
|
+
reasoning_efforts=supported,
|
|
68
|
+
default_reasoning_effort=default if default in supported else None,
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
return tuple(models)
|
convo/llm/streaming.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Collect ChatGPT's required streamed Responses API events into one answer."""
|
|
2
|
+
|
|
3
|
+
from typing import cast
|
|
4
|
+
|
|
5
|
+
from openai import AsyncOpenAI, omit
|
|
6
|
+
from openai.types.responses import ResponseFormatTextJSONSchemaConfigParam, ResponseInputParam
|
|
7
|
+
|
|
8
|
+
from convo.core.contracts import ReasoningEffort
|
|
9
|
+
from convo.core.exceptions import BackendNotConnected
|
|
10
|
+
from convo.prompts.schema import response_format
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def complete_chatgpt(
|
|
14
|
+
access_token: str,
|
|
15
|
+
model: str,
|
|
16
|
+
instructions: str,
|
|
17
|
+
input_items: ResponseInputParam,
|
|
18
|
+
reasoning_effort: ReasoningEffort | None = None,
|
|
19
|
+
*,
|
|
20
|
+
structured: bool = False,
|
|
21
|
+
) -> str:
|
|
22
|
+
"""Return text only after a successful terminal response event."""
|
|
23
|
+
async with AsyncOpenAI(
|
|
24
|
+
api_key=access_token,
|
|
25
|
+
base_url="https://api.openai.com/v1",
|
|
26
|
+
max_retries=0,
|
|
27
|
+
timeout=90,
|
|
28
|
+
) as client:
|
|
29
|
+
stream = await client.responses.create(
|
|
30
|
+
model=model,
|
|
31
|
+
instructions=instructions,
|
|
32
|
+
input=input_items,
|
|
33
|
+
store=False,
|
|
34
|
+
stream=True,
|
|
35
|
+
reasoning={"effort": reasoning_effort} if reasoning_effort is not None else omit,
|
|
36
|
+
text={"format": cast(ResponseFormatTextJSONSchemaConfigParam, response_format())}
|
|
37
|
+
if structured
|
|
38
|
+
else omit,
|
|
39
|
+
)
|
|
40
|
+
text: list[str] = []
|
|
41
|
+
completed = False
|
|
42
|
+
async with stream:
|
|
43
|
+
async for event in stream:
|
|
44
|
+
if event.type == "response.output_text.delta":
|
|
45
|
+
text.append(event.delta)
|
|
46
|
+
elif event.type == "response.refusal.delta":
|
|
47
|
+
raise BackendNotConnected(
|
|
48
|
+
"ChatGPT declined this request. Try a different question."
|
|
49
|
+
)
|
|
50
|
+
elif event.type == "response.failed":
|
|
51
|
+
error = event.response.error
|
|
52
|
+
code = error.code if error else "unknown_error"
|
|
53
|
+
raise RuntimeError(f"ChatGPT response failed ({code})")
|
|
54
|
+
elif event.type == "response.incomplete":
|
|
55
|
+
raise RuntimeError("ChatGPT response was incomplete")
|
|
56
|
+
elif event.type == "error":
|
|
57
|
+
raise RuntimeError("ChatGPT stream returned an error")
|
|
58
|
+
elif event.type == "response.completed":
|
|
59
|
+
completed = True
|
|
60
|
+
if not completed:
|
|
61
|
+
raise RuntimeError("ChatGPT stream ended before response.completed")
|
|
62
|
+
return "".join(text)
|
convo/migrations/env.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from alembic import context
|
|
2
|
+
from sqlalchemy import create_engine, pool
|
|
3
|
+
|
|
4
|
+
from convo.core.config import load_settings
|
|
5
|
+
from convo.db import models as models
|
|
6
|
+
from convo.db.base import Base
|
|
7
|
+
|
|
8
|
+
config = context.config
|
|
9
|
+
settings = load_settings()
|
|
10
|
+
target_metadata = Base.metadata
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run_migrations_offline() -> None:
|
|
14
|
+
context.configure(
|
|
15
|
+
url=settings.database_url,
|
|
16
|
+
target_metadata=target_metadata,
|
|
17
|
+
literal_binds=True,
|
|
18
|
+
dialect_opts={"paramstyle": "named"},
|
|
19
|
+
compare_type=True,
|
|
20
|
+
)
|
|
21
|
+
with context.begin_transaction():
|
|
22
|
+
context.run_migrations()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def run_migrations_online() -> None:
|
|
26
|
+
engine = create_engine(settings.database_url, poolclass=pool.NullPool)
|
|
27
|
+
try:
|
|
28
|
+
with engine.connect() as connection:
|
|
29
|
+
context.configure(
|
|
30
|
+
connection=connection,
|
|
31
|
+
target_metadata=target_metadata,
|
|
32
|
+
compare_type=True,
|
|
33
|
+
)
|
|
34
|
+
with context.begin_transaction():
|
|
35
|
+
context.run_migrations()
|
|
36
|
+
finally:
|
|
37
|
+
engine.dispose()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
if context.is_offline_mode():
|
|
41
|
+
run_migrations_offline()
|
|
42
|
+
else:
|
|
43
|
+
run_migrations_online()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""${message}
|
|
2
|
+
|
|
3
|
+
Revision ID: ${up_revision}
|
|
4
|
+
Revises: ${down_revision | comma,n}
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from alembic import op
|
|
8
|
+
import sqlalchemy as sa
|
|
9
|
+
${imports if imports else ""}
|
|
10
|
+
|
|
11
|
+
revision = ${repr(up_revision)}
|
|
12
|
+
down_revision = ${repr(down_revision)}
|
|
13
|
+
branch_labels = ${repr(branch_labels)}
|
|
14
|
+
depends_on = ${repr(depends_on)}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def upgrade() -> None:
|
|
18
|
+
${upgrades if upgrades else "pass"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def downgrade() -> None:
|
|
22
|
+
${downgrades if downgrades else "pass"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""describe your schema change
|
|
2
|
+
|
|
3
|
+
Revision ID: 5c3b75f9c5c1
|
|
4
|
+
Revises: 732cf62e465c
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
revision = "5c3b75f9c5c1"
|
|
8
|
+
down_revision = "732cf62e465c"
|
|
9
|
+
branch_labels = None
|
|
10
|
+
depends_on = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def upgrade() -> None:
|
|
14
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
|
15
|
+
pass
|
|
16
|
+
# ### end Alembic commands ###
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def downgrade() -> None:
|
|
20
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
|
21
|
+
pass
|
|
22
|
+
# ### end Alembic commands ###
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""create rag tables
|
|
2
|
+
|
|
3
|
+
Revision ID: 732cf62e465c
|
|
4
|
+
Revises: None
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sqlalchemy as sa
|
|
8
|
+
from alembic import op
|
|
9
|
+
from pgvector.sqlalchemy import VECTOR
|
|
10
|
+
|
|
11
|
+
revision = "732cf62e465c"
|
|
12
|
+
down_revision = None
|
|
13
|
+
branch_labels = None
|
|
14
|
+
depends_on = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def upgrade() -> None:
|
|
18
|
+
op.execute("CREATE EXTENSION IF NOT EXISTS vector")
|
|
19
|
+
op.create_table(
|
|
20
|
+
"documents",
|
|
21
|
+
sa.Column("id", sa.UUID(), nullable=False),
|
|
22
|
+
sa.Column("source_path", sa.Text(), nullable=False),
|
|
23
|
+
sa.Column("content_hash", sa.String(64), nullable=False),
|
|
24
|
+
sa.Column("index_signature", sa.Text(), nullable=False),
|
|
25
|
+
sa.PrimaryKeyConstraint("id", name="pk_documents"),
|
|
26
|
+
)
|
|
27
|
+
op.create_table(
|
|
28
|
+
"chunks",
|
|
29
|
+
sa.Column("id", sa.Integer(), nullable=False),
|
|
30
|
+
sa.Column("document_id", sa.UUID(), nullable=False),
|
|
31
|
+
sa.Column("ordinal", sa.Integer(), nullable=False),
|
|
32
|
+
sa.Column("text", sa.Text(), nullable=False),
|
|
33
|
+
sa.Column("locator", sa.Text(), nullable=False),
|
|
34
|
+
sa.Column("embedding", VECTOR(1024), nullable=False),
|
|
35
|
+
sa.ForeignKeyConstraint(
|
|
36
|
+
["document_id"],
|
|
37
|
+
["documents.id"],
|
|
38
|
+
name="fk_chunks_document_id_documents",
|
|
39
|
+
ondelete="CASCADE",
|
|
40
|
+
),
|
|
41
|
+
sa.PrimaryKeyConstraint("id", name="pk_chunks"),
|
|
42
|
+
sa.UniqueConstraint("document_id", "ordinal", name="uq_chunks_document_id"),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def downgrade() -> None:
|
|
47
|
+
op.drop_table("chunks")
|
|
48
|
+
op.drop_table("documents")
|
|
49
|
+
# Retain the extension: another application
|