synaptane 1.0.0rc2__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.
- synaptane/__init__.py +53 -0
- synaptane/__main__.py +3 -0
- synaptane/ai/__init__.py +1 -0
- synaptane/ai/anthropic_provider.py +160 -0
- synaptane/ai/base.py +137 -0
- synaptane/ai/cli_agents.py +535 -0
- synaptane/ai/cli_contract.py +427 -0
- synaptane/ai/embeddings.py +148 -0
- synaptane/ai/openai_compat.py +123 -0
- synaptane/ai/pricing.py +119 -0
- synaptane/ai/providers.py +81 -0
- synaptane/ai/quota.py +94 -0
- synaptane/api/__init__.py +1 -0
- synaptane/api/content.py +694 -0
- synaptane/api/health.py +101 -0
- synaptane/api/hooks.py +38 -0
- synaptane/api/integrations.py +268 -0
- synaptane/api/routes.py +557 -0
- synaptane/api/routines.py +197 -0
- synaptane/api/tidy.py +277 -0
- synaptane/api/vocation.py +151 -0
- synaptane/cli.py +670 -0
- synaptane/config.py +147 -0
- synaptane/core/__init__.py +1 -0
- synaptane/core/access.py +93 -0
- synaptane/core/errors.py +93 -0
- synaptane/core/logs.py +68 -0
- synaptane/core/markdown.py +458 -0
- synaptane/core/names.py +54 -0
- synaptane/core/secret_scan.py +184 -0
- synaptane/core/security.py +74 -0
- synaptane/core/throttle.py +115 -0
- synaptane/extract.py +593 -0
- synaptane/extract_ppt.py +433 -0
- synaptane/git/__init__.py +1 -0
- synaptane/git/credential_helper.py +46 -0
- synaptane/git/credentials.py +218 -0
- synaptane/git/repo.py +1027 -0
- synaptane/mcp/__init__.py +1 -0
- synaptane/mcp/errors.py +100 -0
- synaptane/mcp/kit.py +216 -0
- synaptane/mcp/oauth.py +466 -0
- synaptane/mcp/pages.py +140 -0
- synaptane/mcp/prompts.py +116 -0
- synaptane/mcp/reading.py +707 -0
- synaptane/mcp/server.py +606 -0
- synaptane/py.typed +0 -0
- synaptane/server.py +323 -0
- synaptane/services/__init__.py +92 -0
- synaptane/services/accounts.py +514 -0
- synaptane/services/activity.py +88 -0
- synaptane/services/ai_settings.py +301 -0
- synaptane/services/alerts.py +210 -0
- synaptane/services/assistants.py +52 -0
- synaptane/services/backup.py +376 -0
- synaptane/services/budget.py +252 -0
- synaptane/services/changes.py +417 -0
- synaptane/services/content.py +492 -0
- synaptane/services/demo.py +315 -0
- synaptane/services/deposits.py +478 -0
- synaptane/services/extraction.py +301 -0
- synaptane/services/freshness.py +667 -0
- synaptane/services/git_sync.py +736 -0
- synaptane/services/health.py +316 -0
- synaptane/services/inbox.py +142 -0
- synaptane/services/inbox_text.py +66 -0
- synaptane/services/invitations.py +230 -0
- synaptane/services/jobs.py +216 -0
- synaptane/services/links.py +127 -0
- synaptane/services/mail.py +447 -0
- synaptane/services/offsite.py +221 -0
- synaptane/services/organizations.py +118 -0
- synaptane/services/related.py +967 -0
- synaptane/services/routine_agent.py +1376 -0
- synaptane/services/routines.py +539 -0
- synaptane/services/search.py +1056 -0
- synaptane/services/secrets.py +126 -0
- synaptane/services/semantic.py +464 -0
- synaptane/services/spaces.py +169 -0
- synaptane/services/sso.py +467 -0
- synaptane/services/tidy.py +1157 -0
- synaptane/services/tidy_commit.py +133 -0
- synaptane/services/tidy_journal.py +616 -0
- synaptane/services/tidy_metrics.py +259 -0
- synaptane/services/tidy_notices.py +121 -0
- synaptane/services/tidy_tools.py +1130 -0
- synaptane/services/vocation.py +481 -0
- synaptane/services/vocation_templates/blank.fr.md +40 -0
- synaptane/services/vocation_templates/blank.md +40 -0
- synaptane/services/vocation_templates/client.fr.md +46 -0
- synaptane/services/vocation_templates/client.md +46 -0
- synaptane/services/vocation_templates/family.fr.md +47 -0
- synaptane/services/vocation_templates/family.md +47 -0
- synaptane/services/vocation_templates/infra.fr.md +48 -0
- synaptane/services/vocation_templates/infra.md +48 -0
- synaptane/services/vocation_templates/job-search.fr.md +42 -0
- synaptane/services/vocation_templates/job-search.md +42 -0
- synaptane/services/vocation_templates/marketing.fr.md +48 -0
- synaptane/services/vocation_templates/marketing.md +48 -0
- synaptane/services/vocation_templates/personal.fr.md +43 -0
- synaptane/services/vocation_templates/personal.md +43 -0
- synaptane/services/vocation_templates/product.fr.md +45 -0
- synaptane/services/vocation_templates/product.md +45 -0
- synaptane/services/webhooks.py +304 -0
- synaptane/services/workspace.py +86 -0
- synaptane/storage/__init__.py +1 -0
- synaptane/storage/db.py +150 -0
- synaptane/storage/migrations/env.py +13 -0
- synaptane/storage/migrations/script.py.mako +23 -0
- synaptane/storage/migrations/versions/0001_comptes_organisations_espaces.py +220 -0
- synaptane/storage/migrations/versions/0002_synchronisation_git_et_taches.py +81 -0
- synaptane/storage/migrations/versions/0003_oauth_mcp.py +96 -0
- synaptane/storage/migrations/versions/0004_rangement_nocturne.py +105 -0
- synaptane/storage/migrations/versions/0005_recherche_hybride.py +68 -0
- synaptane/storage/migrations/versions/0006_sso_et_webhooks.py +107 -0
- synaptane/storage/migrations/versions/0007_invitations_et_courriels.py +104 -0
- synaptane/storage/migrations/versions/0008_modifications_calculees_depuis_git.py +47 -0
- synaptane/storage/migrations/versions/0009_cout_et_budget_du_rangement.py +38 -0
- synaptane/storage/migrations/versions/0010_jetons_restreints.py +35 -0
- synaptane/storage/migrations/versions/0011_activite_des_assistants.py +36 -0
- synaptane/storage/migrations/versions/0012_verification_des_emails.py +45 -0
- synaptane/storage/migrations/versions/0013_finitions.py +32 -0
- synaptane/storage/migrations/versions/0014_securite.py +39 -0
- synaptane/storage/migrations/versions/0015_quota_abonnement.py +24 -0
- synaptane/storage/migrations/versions/0016_rangement_robuste.py +37 -0
- synaptane/storage/migrations/versions/0017_disjoncteur_des_webhooks.py +27 -0
- synaptane/storage/migrations/versions/0018_rangement_au_fil_de_l_eau.py +28 -0
- synaptane/storage/migrations/versions/0019_qualite_du_rangement.py +25 -0
- synaptane/storage/migrations/versions/0020_routines_d_agent.py +141 -0
- synaptane/storage/migrations/versions/0021_verification_des_pages.py +29 -0
- synaptane/storage/migrations/versions/0022_domaines_sso.py +35 -0
- synaptane/storage/migrations/versions/0023_seuil_de_sens.py +28 -0
- synaptane/storage/models.py +655 -0
- synaptane/web/__init__.py +1 -0
- synaptane/web/account_pages.py +302 -0
- synaptane/web/admin_pages.py +145 -0
- synaptane/web/health_pages.py +37 -0
- synaptane/web/home_pages.py +202 -0
- synaptane/web/i18n.py +92 -0
- synaptane/web/inbox_pages.py +485 -0
- synaptane/web/invite_pages.py +305 -0
- synaptane/web/locales/fr.json +1487 -0
- synaptane/web/login_pages.py +94 -0
- synaptane/web/oauth_pages.py +91 -0
- synaptane/web/org_pages.py +281 -0
- synaptane/web/pages.py +821 -0
- synaptane/web/render.py +137 -0
- synaptane/web/routine_pages.py +382 -0
- synaptane/web/space_pages.py +288 -0
- synaptane/web/sso_pages.py +235 -0
- synaptane/web/static/app.css +1282 -0
- synaptane/web/static/fonts/Geist-latin.woff2 +0 -0
- synaptane/web/static/fonts/GeistMono-latin.woff2 +0 -0
- synaptane/web/static/fonts/InstrumentSans-latin.woff2 +0 -0
- synaptane/web/static/fonts/JetBrainsMono-latin.woff2 +0 -0
- synaptane/web/static/fonts/Newsreader-italic-latin.woff2 +0 -0
- synaptane/web/static/fonts/Newsreader-latin.woff2 +0 -0
- synaptane/web/static/fonts/OFL-Geist.txt +93 -0
- synaptane/web/static/fonts/OFL-InstrumentSans.txt +93 -0
- synaptane/web/static/fonts/OFL-JetBrainsMono.txt +93 -0
- synaptane/web/static/fonts/OFL-Newsreader.txt +93 -0
- synaptane/web/static/icon.svg +1 -0
- synaptane/web/static/preferences.js +731 -0
- synaptane/web/templates/account-assistants.html +95 -0
- synaptane/web/templates/account-base.html +26 -0
- synaptane/web/templates/account-profile.html +46 -0
- synaptane/web/templates/account-security.html +44 -0
- synaptane/web/templates/admin-base.html +16 -0
- synaptane/web/templates/admin-mail.html +43 -0
- synaptane/web/templates/admin-orgs.html +36 -0
- synaptane/web/templates/admin-sso.html +55 -0
- synaptane/web/templates/admin-user.html +55 -0
- synaptane/web/templates/admin-users.html +40 -0
- synaptane/web/templates/base.html +109 -0
- synaptane/web/templates/consent.html +38 -0
- synaptane/web/templates/deposit-form.html +31 -0
- synaptane/web/templates/deposit.html +23 -0
- synaptane/web/templates/error.html +28 -0
- synaptane/web/templates/forgot.html +17 -0
- synaptane/web/templates/home.html +118 -0
- synaptane/web/templates/icons.html +61 -0
- synaptane/web/templates/inbox.html +112 -0
- synaptane/web/templates/invite.html +24 -0
- synaptane/web/templates/login.html +43 -0
- synaptane/web/templates/members-table.html +41 -0
- synaptane/web/templates/org-ai.html +94 -0
- synaptane/web/templates/org-base.html +27 -0
- synaptane/web/templates/org-embeddings.html +32 -0
- synaptane/web/templates/org-members.html +52 -0
- synaptane/web/templates/org-new-space.html +20 -0
- synaptane/web/templates/org-settings.html +17 -0
- synaptane/web/templates/org-spaces.html +25 -0
- synaptane/web/templates/org-webhooks.html +36 -0
- synaptane/web/templates/provider-help.html +55 -0
- synaptane/web/templates/public-card.html +9 -0
- synaptane/web/templates/reset.html +16 -0
- synaptane/web/templates/routine-ui.html +27 -0
- synaptane/web/templates/search.html +36 -0
- synaptane/web/templates/space-activity.html +33 -0
- synaptane/web/templates/space-compare.html +10 -0
- synaptane/web/templates/space-general.html +18 -0
- synaptane/web/templates/space-git.html +127 -0
- synaptane/web/templates/space-header.html +9 -0
- synaptane/web/templates/space-health.html +85 -0
- synaptane/web/templates/space-members.html +12 -0
- synaptane/web/templates/space-routine-form.html +57 -0
- synaptane/web/templates/space-routine.html +92 -0
- synaptane/web/templates/space-routines.html +70 -0
- synaptane/web/templates/space-semantic.html +13 -0
- synaptane/web/templates/space-settings-base.html +24 -0
- synaptane/web/templates/space-sidebar.html +38 -0
- synaptane/web/templates/space-tabs.html +21 -0
- synaptane/web/templates/space-tidy-settings.html +29 -0
- synaptane/web/templates/space-tree-fragment.html +2 -0
- synaptane/web/templates/space-tree.html +22 -0
- synaptane/web/templates/space-vocation.html +93 -0
- synaptane/web/templates/theme-control.html +2 -0
- synaptane/web/templates/tidy-metrics.html +28 -0
- synaptane/web/templates/tidy-run-now.html +8 -0
- synaptane/web/templates/tidy.html +139 -0
- synaptane/web/templates/ui.html +90 -0
- synaptane/web/templates/verify-email.html +22 -0
- synaptane/web/templates/wiki-related.html +28 -0
- synaptane/web/templates/wiki.html +254 -0
- synaptane/web/tidy_pages.py +395 -0
- synaptane/web/vocation_pages.py +213 -0
- synaptane/web/wiki.py +790 -0
- synaptane/worker/__init__.py +1 -0
- synaptane/worker/runner.py +382 -0
- synaptane-1.0.0rc2.dist-info/METADATA +356 -0
- synaptane-1.0.0rc2.dist-info/RECORD +236 -0
- synaptane-1.0.0rc2.dist-info/WHEEL +4 -0
- synaptane-1.0.0rc2.dist-info/entry_points.txt +2 -0
- synaptane-1.0.0rc2.dist-info/licenses/LICENSE +202 -0
- synaptane-1.0.0rc2.dist-info/licenses/NOTICE +8 -0
- synaptane-1.0.0rc2.dist-info/licenses/THIRD_PARTY_NOTICES.md +350 -0
synaptane/__init__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Synaptane: a team second brain stored in Git."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
from functools import cache
|
|
7
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
__version__ = version("synaptane")
|
|
12
|
+
except PackageNotFoundError: # running from the sources without installing
|
|
13
|
+
__version__ = "0+unknown"
|
|
14
|
+
|
|
15
|
+
UNKNOWN_COMMIT = "unknown"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@cache
|
|
19
|
+
def git_commit() -> str:
|
|
20
|
+
"""Commit the running code comes from, to know afterwards which version did what.
|
|
21
|
+
|
|
22
|
+
The version number (`1.0.0rc1`) is the same for every commit between two releases:
|
|
23
|
+
only the commit tells two deployments apart. In the image, it is written at build time
|
|
24
|
+
(`--build-arg GIT_SHA=…` → `SYNAPTANE_GIT_SHA`); from the sources, it is asked of the
|
|
25
|
+
repository that contains this package. Otherwise: "unknown", never an error.
|
|
26
|
+
"""
|
|
27
|
+
sha = os.environ.get("SYNAPTANE_GIT_SHA", "").strip()
|
|
28
|
+
if sha and sha != "unknown": # "unknown": image built without the GIT_SHA argument
|
|
29
|
+
return sha
|
|
30
|
+
try:
|
|
31
|
+
result = subprocess.run(
|
|
32
|
+
["git", "rev-parse", "--verify", "--quiet", "HEAD"],
|
|
33
|
+
cwd=Path(__file__).parent,
|
|
34
|
+
capture_output=True,
|
|
35
|
+
text=True,
|
|
36
|
+
timeout=5,
|
|
37
|
+
check=False,
|
|
38
|
+
)
|
|
39
|
+
except (OSError, subprocess.SubprocessError): # git missing, unreadable folder…
|
|
40
|
+
return UNKNOWN_COMMIT
|
|
41
|
+
sha = result.stdout.strip()
|
|
42
|
+
return sha if result.returncode == 0 and sha else UNKNOWN_COMMIT
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def short_commit() -> str:
|
|
46
|
+
"""The first 7 characters of a SHA (like GitHub), or the value as is."""
|
|
47
|
+
sha = git_commit()
|
|
48
|
+
return sha[:7] if re.fullmatch(r"[0-9a-f]{8,64}", sha) else sha
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def version_label() -> str:
|
|
52
|
+
"""`1.0.0rc1 (abc1234)`: what `--version`, `doctor` and the administration show."""
|
|
53
|
+
return f"{__version__} ({short_commit()})"
|
synaptane/__main__.py
ADDED
synaptane/ai/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""AI providers: a tool-driven conversation, whatever the provider."""
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Claude through the official `anthropic` SDK (manual tool loop, shared by all providers).
|
|
2
|
+
|
|
3
|
+
- Adaptive thinking by default on recent models; depth set by `effort`.
|
|
4
|
+
- Server-side fallback (`fallbacks: "default"`): if a request is refused by a safety filter,
|
|
5
|
+
the API replays it on a fallback model instead of failing.
|
|
6
|
+
- The history is kept as is (response blocks sent back in full).
|
|
7
|
+
- Strict tools (`strict: true`) when the model accepts them: the schemas are closed.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import Any, cast
|
|
11
|
+
|
|
12
|
+
import anthropic
|
|
13
|
+
from anthropic.types.beta import BetaMessageParam, BetaOutputConfigParam, BetaToolParam
|
|
14
|
+
|
|
15
|
+
from synaptane.ai.base import AIConfig, AIError, ToolCall, ToolResult, ToolSpec, Turn
|
|
16
|
+
|
|
17
|
+
MAX_TOKENS = 16000
|
|
18
|
+
FALLBACK_BETA = "server-side-fallback-2026-07-01"
|
|
19
|
+
# Server-side fallback: only these models, and only on the Claude API itself.
|
|
20
|
+
FALLBACK_MODELS = ("claude-opus-5-5", "claude-opus-5", "claude-fable-5-1", "claude-sonnet-5-5")
|
|
21
|
+
NO_EFFORT_MODELS = ("claude-haiku",) # `effort` refused
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def request_options(config: AIConfig) -> dict[str, Any]:
|
|
25
|
+
"""Claude-specific parameters, depending on the model and the endpoint (gateway or not)."""
|
|
26
|
+
model = config.model.strip().lower()
|
|
27
|
+
options: dict[str, Any] = {}
|
|
28
|
+
if not model.startswith(NO_EFFORT_MODELS):
|
|
29
|
+
options["output_config"] = cast(BetaOutputConfigParam, {"effort": config.effort})
|
|
30
|
+
if not config.base_url and any(
|
|
31
|
+
model == m or model.startswith(m + "-") for m in FALLBACK_MODELS
|
|
32
|
+
):
|
|
33
|
+
options["betas"] = [FALLBACK_BETA]
|
|
34
|
+
options["fallbacks"] = "default"
|
|
35
|
+
return options
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Strict tools (arguments always match the schema): models that accept them.
|
|
39
|
+
STRICT_MODELS = (
|
|
40
|
+
"claude-fable-5",
|
|
41
|
+
"claude-mythos-5",
|
|
42
|
+
"claude-opus-5",
|
|
43
|
+
"claude-opus-4-8",
|
|
44
|
+
"claude-sonnet-5",
|
|
45
|
+
"claude-haiku-4-5",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _closed(schema: object) -> bool:
|
|
50
|
+
"""Every object of the schema is closed (`additionalProperties: false`), a condition of
|
|
51
|
+
strict mode; otherwise the API would refuse the request."""
|
|
52
|
+
if isinstance(schema, dict):
|
|
53
|
+
if schema.get("type") == "object" and schema.get("additionalProperties") is not False:
|
|
54
|
+
return False
|
|
55
|
+
return all(_closed(v) for v in schema.values())
|
|
56
|
+
if isinstance(schema, list):
|
|
57
|
+
return all(_closed(v) for v in schema)
|
|
58
|
+
return True
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def tool_param(config: AIConfig, tool: ToolSpec) -> BetaToolParam:
|
|
62
|
+
"""Tool definition for the API. `strict: true` guarantees valid arguments (no invented
|
|
63
|
+
parameter, no wrong type): only on the Claude API itself (a gateway may not know it), for
|
|
64
|
+
a model that accepts it and a closed schema."""
|
|
65
|
+
param: BetaToolParam = {
|
|
66
|
+
"name": tool.name,
|
|
67
|
+
"description": tool.description,
|
|
68
|
+
"input_schema": tool.input_schema,
|
|
69
|
+
}
|
|
70
|
+
model = config.model.strip().lower()
|
|
71
|
+
if not config.base_url and model.startswith(STRICT_MODELS) and _closed(tool.input_schema):
|
|
72
|
+
param["strict"] = True
|
|
73
|
+
return param
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class AnthropicConversation:
|
|
77
|
+
def __init__(
|
|
78
|
+
self, config: AIConfig, system: str, first_message: str, tools: list[ToolSpec]
|
|
79
|
+
) -> None:
|
|
80
|
+
self.config = config
|
|
81
|
+
self.client = anthropic.Anthropic(
|
|
82
|
+
api_key=config.api_key, base_url=config.base_url or None, max_retries=3
|
|
83
|
+
)
|
|
84
|
+
self.system = system
|
|
85
|
+
self.tools = [tool_param(config, t) for t in tools]
|
|
86
|
+
self.messages: list[BetaMessageParam] = [{"role": "user", "content": first_message}]
|
|
87
|
+
|
|
88
|
+
def step(self) -> Turn:
|
|
89
|
+
try:
|
|
90
|
+
response = self.client.beta.messages.create(
|
|
91
|
+
model=self.config.model,
|
|
92
|
+
max_tokens=MAX_TOKENS,
|
|
93
|
+
system=self.system,
|
|
94
|
+
tools=self.tools,
|
|
95
|
+
messages=self.messages,
|
|
96
|
+
**request_options(self.config),
|
|
97
|
+
# Append-only history: each turn reads the previous one back from the cache.
|
|
98
|
+
cache_control={"type": "ephemeral"},
|
|
99
|
+
)
|
|
100
|
+
except (anthropic.AuthenticationError, anthropic.PermissionDeniedError) as error:
|
|
101
|
+
raise AIError("Anthropic key refused: {detail}", detail=error.message) from None
|
|
102
|
+
except anthropic.NotFoundError as error:
|
|
103
|
+
raise AIError(
|
|
104
|
+
"Model not found ({model}): {detail}", model=self.config.model, detail=error.message
|
|
105
|
+
) from None
|
|
106
|
+
except anthropic.BadRequestError as error:
|
|
107
|
+
raise AIError("Request refused by Anthropic: {detail}", detail=error.message) from None
|
|
108
|
+
except anthropic.RateLimitError:
|
|
109
|
+
raise AIError("Anthropic rate limit reached.", transient=True) from None
|
|
110
|
+
except anthropic.APIStatusError as error:
|
|
111
|
+
transient = error.status_code >= 500
|
|
112
|
+
raise AIError(
|
|
113
|
+
"Anthropic error {status}", transient=transient, status=error.status_code
|
|
114
|
+
) from None
|
|
115
|
+
except anthropic.APIConnectionError:
|
|
116
|
+
raise AIError("Anthropic unreachable.", transient=True) from None
|
|
117
|
+
|
|
118
|
+
# Append-only history: the full content is sent back on the next turn.
|
|
119
|
+
self.messages.append({"role": "assistant", "content": response.content})
|
|
120
|
+
calls = [
|
|
121
|
+
ToolCall(block.id, block.name, dict(block.input))
|
|
122
|
+
for block in response.content
|
|
123
|
+
if block.type == "tool_use"
|
|
124
|
+
]
|
|
125
|
+
text = "".join(block.text for block in response.content if block.type == "text")
|
|
126
|
+
stop = {
|
|
127
|
+
"tool_use": "tool_use",
|
|
128
|
+
"max_tokens": "max_tokens",
|
|
129
|
+
"refusal": "refusal",
|
|
130
|
+
}.get(str(response.stop_reason), "end")
|
|
131
|
+
if calls and stop == "end":
|
|
132
|
+
stop = "tool_use" # a truncation (max_tokens) or a refusal stays visible
|
|
133
|
+
# Cache writes: counted in the input, and separately for their higher price
|
|
134
|
+
# (1.25 × the input with the 5-minute duration of automatic caching).
|
|
135
|
+
written = response.usage.cache_creation_input_tokens or 0
|
|
136
|
+
return Turn(
|
|
137
|
+
text=text,
|
|
138
|
+
tool_calls=calls,
|
|
139
|
+
stop=stop, # type: ignore[arg-type]
|
|
140
|
+
input_tokens=response.usage.input_tokens + written,
|
|
141
|
+
output_tokens=response.usage.output_tokens,
|
|
142
|
+
cached_tokens=response.usage.cache_read_input_tokens or 0,
|
|
143
|
+
cache_write_tokens=written,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def reply(self, results: list[ToolResult]) -> None:
|
|
147
|
+
# All results of a turn in a single user message.
|
|
148
|
+
content: list[Any] = [
|
|
149
|
+
{
|
|
150
|
+
"type": "tool_result",
|
|
151
|
+
"tool_use_id": r.call_id,
|
|
152
|
+
"content": r.content,
|
|
153
|
+
"is_error": r.is_error,
|
|
154
|
+
}
|
|
155
|
+
for r in results
|
|
156
|
+
]
|
|
157
|
+
self.messages.append({"role": "user", "content": content})
|
|
158
|
+
|
|
159
|
+
def nudge(self, text: str) -> None:
|
|
160
|
+
self.messages.append({"role": "user", "content": text})
|
synaptane/ai/base.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Common provider interface: a conversation that moves forward in tool turns."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, Literal, Protocol
|
|
5
|
+
|
|
6
|
+
from synaptane.ai import quota
|
|
7
|
+
from synaptane.core.errors import Label
|
|
8
|
+
|
|
9
|
+
API_PROVIDERS = ("anthropic", "openai", "mistral", "ollama", "openai_compatible")
|
|
10
|
+
# Subscriptions: the official CLI, logged in on the server, runs the agent (cli_agents).
|
|
11
|
+
SUBSCRIPTION_PROVIDERS = ("claude_code", "codex")
|
|
12
|
+
PROVIDERS = API_PROVIDERS + SUBSCRIPTION_PROVIDERS
|
|
13
|
+
KEYLESS_PROVIDERS = ("ollama", "openai_compatible", *SUBSCRIPTION_PROVIDERS)
|
|
14
|
+
EFFORTS = ("low", "medium", "high", "xhigh", "max")
|
|
15
|
+
|
|
16
|
+
DEFAULT_MODELS: dict[str, str] = {
|
|
17
|
+
"anthropic": "claude-sonnet-5-5", # tidy-up: good value for money
|
|
18
|
+
"mistral": "mistral-large-latest",
|
|
19
|
+
}
|
|
20
|
+
DEFAULT_BASE_URLS: dict[str, str] = {
|
|
21
|
+
"openai": "https://api.openai.com/v1",
|
|
22
|
+
"mistral": "https://api.mistral.ai/v1",
|
|
23
|
+
"ollama": "http://localhost:11434/v1",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AIError(Exception):
|
|
28
|
+
"""Failed call to the provider. `transient`: retrying later makes sense. `usage`: what was
|
|
29
|
+
consumed before the failure (a CLI that ends in error may have worked and used up quota):
|
|
30
|
+
it must be counted anyway. `label`: the message to translate (English template and
|
|
31
|
+
`params`); `str(error)` is the English text."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
template: str,
|
|
36
|
+
*,
|
|
37
|
+
transient: bool = False,
|
|
38
|
+
usage: "Usage | None" = None,
|
|
39
|
+
**params: object,
|
|
40
|
+
) -> None:
|
|
41
|
+
self.label = Label(template, **params)
|
|
42
|
+
super().__init__(str(self.label))
|
|
43
|
+
self.transient = transient
|
|
44
|
+
self.usage = usage
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class AIConfig:
|
|
49
|
+
provider: str
|
|
50
|
+
model: str
|
|
51
|
+
api_key: str = ""
|
|
52
|
+
base_url: str = ""
|
|
53
|
+
effort: str = "medium" # Claude only: reasoning depth
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def endpoint(self) -> str:
|
|
57
|
+
return (self.base_url or DEFAULT_BASE_URLS.get(self.provider, "")).rstrip("/")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class ToolSpec:
|
|
62
|
+
name: str
|
|
63
|
+
description: str
|
|
64
|
+
input_schema: dict[str, Any]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class ToolCall:
|
|
69
|
+
id: str
|
|
70
|
+
name: str
|
|
71
|
+
input: dict[str, Any]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class Turn:
|
|
76
|
+
text: str
|
|
77
|
+
tool_calls: list[ToolCall]
|
|
78
|
+
stop: Literal["end", "tool_use", "max_tokens", "refusal"]
|
|
79
|
+
input_tokens: int = 0 # input billed at full price (cache writes included)
|
|
80
|
+
output_tokens: int = 0
|
|
81
|
+
cached_tokens: int = 0 # input read back from the cache
|
|
82
|
+
cache_write_tokens: int = 0 # share of `input_tokens` written to the cache (higher price)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class ToolResult:
|
|
87
|
+
call_id: str
|
|
88
|
+
content: str
|
|
89
|
+
is_error: bool = False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class Usage:
|
|
94
|
+
input_tokens: int = 0
|
|
95
|
+
output_tokens: int = 0
|
|
96
|
+
cached_tokens: int = 0
|
|
97
|
+
cache_write_tokens: int = 0 # share of `input_tokens` written to the cache (higher price)
|
|
98
|
+
turns: int = 0
|
|
99
|
+
notes: list[str] = field(default_factory=list)
|
|
100
|
+
quota: dict[str, Any] | None = None # subscription: used share of the windows (ai.quota)
|
|
101
|
+
|
|
102
|
+
def add(self, turn: Turn) -> None:
|
|
103
|
+
self.input_tokens += turn.input_tokens
|
|
104
|
+
self.output_tokens += turn.output_tokens
|
|
105
|
+
self.cached_tokens += turn.cached_tokens
|
|
106
|
+
self.cache_write_tokens += turn.cache_write_tokens
|
|
107
|
+
self.turns += 1
|
|
108
|
+
|
|
109
|
+
def merge(self, other: "Usage") -> None:
|
|
110
|
+
self.input_tokens += other.input_tokens
|
|
111
|
+
self.output_tokens += other.output_tokens
|
|
112
|
+
self.cached_tokens += other.cached_tokens
|
|
113
|
+
self.cache_write_tokens += other.cache_write_tokens
|
|
114
|
+
self.turns += other.turns
|
|
115
|
+
self.notes.extend(other.notes)
|
|
116
|
+
self.quota = quota.merge(self.quota, other.quota)
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def total(self) -> int:
|
|
120
|
+
return self.input_tokens + self.output_tokens
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def weighted(self) -> int:
|
|
124
|
+
"""Weighted tokens for the caps: input read back from the cache counts for a tenth."""
|
|
125
|
+
return self.input_tokens + self.output_tokens + self.cached_tokens // 10
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class Conversation(Protocol):
|
|
129
|
+
"""Provider-native history: `step` asks for a turn, `reply` answers it."""
|
|
130
|
+
|
|
131
|
+
def step(self) -> Turn: ...
|
|
132
|
+
|
|
133
|
+
def reply(self, results: list[ToolResult]) -> None: ...
|
|
134
|
+
|
|
135
|
+
def nudge(self, text: str) -> None:
|
|
136
|
+
"""Prods the agent with a message (e.g. it stopped without calling a tool)."""
|
|
137
|
+
...
|