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.
Files changed (236) hide show
  1. synaptane/__init__.py +53 -0
  2. synaptane/__main__.py +3 -0
  3. synaptane/ai/__init__.py +1 -0
  4. synaptane/ai/anthropic_provider.py +160 -0
  5. synaptane/ai/base.py +137 -0
  6. synaptane/ai/cli_agents.py +535 -0
  7. synaptane/ai/cli_contract.py +427 -0
  8. synaptane/ai/embeddings.py +148 -0
  9. synaptane/ai/openai_compat.py +123 -0
  10. synaptane/ai/pricing.py +119 -0
  11. synaptane/ai/providers.py +81 -0
  12. synaptane/ai/quota.py +94 -0
  13. synaptane/api/__init__.py +1 -0
  14. synaptane/api/content.py +694 -0
  15. synaptane/api/health.py +101 -0
  16. synaptane/api/hooks.py +38 -0
  17. synaptane/api/integrations.py +268 -0
  18. synaptane/api/routes.py +557 -0
  19. synaptane/api/routines.py +197 -0
  20. synaptane/api/tidy.py +277 -0
  21. synaptane/api/vocation.py +151 -0
  22. synaptane/cli.py +670 -0
  23. synaptane/config.py +147 -0
  24. synaptane/core/__init__.py +1 -0
  25. synaptane/core/access.py +93 -0
  26. synaptane/core/errors.py +93 -0
  27. synaptane/core/logs.py +68 -0
  28. synaptane/core/markdown.py +458 -0
  29. synaptane/core/names.py +54 -0
  30. synaptane/core/secret_scan.py +184 -0
  31. synaptane/core/security.py +74 -0
  32. synaptane/core/throttle.py +115 -0
  33. synaptane/extract.py +593 -0
  34. synaptane/extract_ppt.py +433 -0
  35. synaptane/git/__init__.py +1 -0
  36. synaptane/git/credential_helper.py +46 -0
  37. synaptane/git/credentials.py +218 -0
  38. synaptane/git/repo.py +1027 -0
  39. synaptane/mcp/__init__.py +1 -0
  40. synaptane/mcp/errors.py +100 -0
  41. synaptane/mcp/kit.py +216 -0
  42. synaptane/mcp/oauth.py +466 -0
  43. synaptane/mcp/pages.py +140 -0
  44. synaptane/mcp/prompts.py +116 -0
  45. synaptane/mcp/reading.py +707 -0
  46. synaptane/mcp/server.py +606 -0
  47. synaptane/py.typed +0 -0
  48. synaptane/server.py +323 -0
  49. synaptane/services/__init__.py +92 -0
  50. synaptane/services/accounts.py +514 -0
  51. synaptane/services/activity.py +88 -0
  52. synaptane/services/ai_settings.py +301 -0
  53. synaptane/services/alerts.py +210 -0
  54. synaptane/services/assistants.py +52 -0
  55. synaptane/services/backup.py +376 -0
  56. synaptane/services/budget.py +252 -0
  57. synaptane/services/changes.py +417 -0
  58. synaptane/services/content.py +492 -0
  59. synaptane/services/demo.py +315 -0
  60. synaptane/services/deposits.py +478 -0
  61. synaptane/services/extraction.py +301 -0
  62. synaptane/services/freshness.py +667 -0
  63. synaptane/services/git_sync.py +736 -0
  64. synaptane/services/health.py +316 -0
  65. synaptane/services/inbox.py +142 -0
  66. synaptane/services/inbox_text.py +66 -0
  67. synaptane/services/invitations.py +230 -0
  68. synaptane/services/jobs.py +216 -0
  69. synaptane/services/links.py +127 -0
  70. synaptane/services/mail.py +447 -0
  71. synaptane/services/offsite.py +221 -0
  72. synaptane/services/organizations.py +118 -0
  73. synaptane/services/related.py +967 -0
  74. synaptane/services/routine_agent.py +1376 -0
  75. synaptane/services/routines.py +539 -0
  76. synaptane/services/search.py +1056 -0
  77. synaptane/services/secrets.py +126 -0
  78. synaptane/services/semantic.py +464 -0
  79. synaptane/services/spaces.py +169 -0
  80. synaptane/services/sso.py +467 -0
  81. synaptane/services/tidy.py +1157 -0
  82. synaptane/services/tidy_commit.py +133 -0
  83. synaptane/services/tidy_journal.py +616 -0
  84. synaptane/services/tidy_metrics.py +259 -0
  85. synaptane/services/tidy_notices.py +121 -0
  86. synaptane/services/tidy_tools.py +1130 -0
  87. synaptane/services/vocation.py +481 -0
  88. synaptane/services/vocation_templates/blank.fr.md +40 -0
  89. synaptane/services/vocation_templates/blank.md +40 -0
  90. synaptane/services/vocation_templates/client.fr.md +46 -0
  91. synaptane/services/vocation_templates/client.md +46 -0
  92. synaptane/services/vocation_templates/family.fr.md +47 -0
  93. synaptane/services/vocation_templates/family.md +47 -0
  94. synaptane/services/vocation_templates/infra.fr.md +48 -0
  95. synaptane/services/vocation_templates/infra.md +48 -0
  96. synaptane/services/vocation_templates/job-search.fr.md +42 -0
  97. synaptane/services/vocation_templates/job-search.md +42 -0
  98. synaptane/services/vocation_templates/marketing.fr.md +48 -0
  99. synaptane/services/vocation_templates/marketing.md +48 -0
  100. synaptane/services/vocation_templates/personal.fr.md +43 -0
  101. synaptane/services/vocation_templates/personal.md +43 -0
  102. synaptane/services/vocation_templates/product.fr.md +45 -0
  103. synaptane/services/vocation_templates/product.md +45 -0
  104. synaptane/services/webhooks.py +304 -0
  105. synaptane/services/workspace.py +86 -0
  106. synaptane/storage/__init__.py +1 -0
  107. synaptane/storage/db.py +150 -0
  108. synaptane/storage/migrations/env.py +13 -0
  109. synaptane/storage/migrations/script.py.mako +23 -0
  110. synaptane/storage/migrations/versions/0001_comptes_organisations_espaces.py +220 -0
  111. synaptane/storage/migrations/versions/0002_synchronisation_git_et_taches.py +81 -0
  112. synaptane/storage/migrations/versions/0003_oauth_mcp.py +96 -0
  113. synaptane/storage/migrations/versions/0004_rangement_nocturne.py +105 -0
  114. synaptane/storage/migrations/versions/0005_recherche_hybride.py +68 -0
  115. synaptane/storage/migrations/versions/0006_sso_et_webhooks.py +107 -0
  116. synaptane/storage/migrations/versions/0007_invitations_et_courriels.py +104 -0
  117. synaptane/storage/migrations/versions/0008_modifications_calculees_depuis_git.py +47 -0
  118. synaptane/storage/migrations/versions/0009_cout_et_budget_du_rangement.py +38 -0
  119. synaptane/storage/migrations/versions/0010_jetons_restreints.py +35 -0
  120. synaptane/storage/migrations/versions/0011_activite_des_assistants.py +36 -0
  121. synaptane/storage/migrations/versions/0012_verification_des_emails.py +45 -0
  122. synaptane/storage/migrations/versions/0013_finitions.py +32 -0
  123. synaptane/storage/migrations/versions/0014_securite.py +39 -0
  124. synaptane/storage/migrations/versions/0015_quota_abonnement.py +24 -0
  125. synaptane/storage/migrations/versions/0016_rangement_robuste.py +37 -0
  126. synaptane/storage/migrations/versions/0017_disjoncteur_des_webhooks.py +27 -0
  127. synaptane/storage/migrations/versions/0018_rangement_au_fil_de_l_eau.py +28 -0
  128. synaptane/storage/migrations/versions/0019_qualite_du_rangement.py +25 -0
  129. synaptane/storage/migrations/versions/0020_routines_d_agent.py +141 -0
  130. synaptane/storage/migrations/versions/0021_verification_des_pages.py +29 -0
  131. synaptane/storage/migrations/versions/0022_domaines_sso.py +35 -0
  132. synaptane/storage/migrations/versions/0023_seuil_de_sens.py +28 -0
  133. synaptane/storage/models.py +655 -0
  134. synaptane/web/__init__.py +1 -0
  135. synaptane/web/account_pages.py +302 -0
  136. synaptane/web/admin_pages.py +145 -0
  137. synaptane/web/health_pages.py +37 -0
  138. synaptane/web/home_pages.py +202 -0
  139. synaptane/web/i18n.py +92 -0
  140. synaptane/web/inbox_pages.py +485 -0
  141. synaptane/web/invite_pages.py +305 -0
  142. synaptane/web/locales/fr.json +1487 -0
  143. synaptane/web/login_pages.py +94 -0
  144. synaptane/web/oauth_pages.py +91 -0
  145. synaptane/web/org_pages.py +281 -0
  146. synaptane/web/pages.py +821 -0
  147. synaptane/web/render.py +137 -0
  148. synaptane/web/routine_pages.py +382 -0
  149. synaptane/web/space_pages.py +288 -0
  150. synaptane/web/sso_pages.py +235 -0
  151. synaptane/web/static/app.css +1282 -0
  152. synaptane/web/static/fonts/Geist-latin.woff2 +0 -0
  153. synaptane/web/static/fonts/GeistMono-latin.woff2 +0 -0
  154. synaptane/web/static/fonts/InstrumentSans-latin.woff2 +0 -0
  155. synaptane/web/static/fonts/JetBrainsMono-latin.woff2 +0 -0
  156. synaptane/web/static/fonts/Newsreader-italic-latin.woff2 +0 -0
  157. synaptane/web/static/fonts/Newsreader-latin.woff2 +0 -0
  158. synaptane/web/static/fonts/OFL-Geist.txt +93 -0
  159. synaptane/web/static/fonts/OFL-InstrumentSans.txt +93 -0
  160. synaptane/web/static/fonts/OFL-JetBrainsMono.txt +93 -0
  161. synaptane/web/static/fonts/OFL-Newsreader.txt +93 -0
  162. synaptane/web/static/icon.svg +1 -0
  163. synaptane/web/static/preferences.js +731 -0
  164. synaptane/web/templates/account-assistants.html +95 -0
  165. synaptane/web/templates/account-base.html +26 -0
  166. synaptane/web/templates/account-profile.html +46 -0
  167. synaptane/web/templates/account-security.html +44 -0
  168. synaptane/web/templates/admin-base.html +16 -0
  169. synaptane/web/templates/admin-mail.html +43 -0
  170. synaptane/web/templates/admin-orgs.html +36 -0
  171. synaptane/web/templates/admin-sso.html +55 -0
  172. synaptane/web/templates/admin-user.html +55 -0
  173. synaptane/web/templates/admin-users.html +40 -0
  174. synaptane/web/templates/base.html +109 -0
  175. synaptane/web/templates/consent.html +38 -0
  176. synaptane/web/templates/deposit-form.html +31 -0
  177. synaptane/web/templates/deposit.html +23 -0
  178. synaptane/web/templates/error.html +28 -0
  179. synaptane/web/templates/forgot.html +17 -0
  180. synaptane/web/templates/home.html +118 -0
  181. synaptane/web/templates/icons.html +61 -0
  182. synaptane/web/templates/inbox.html +112 -0
  183. synaptane/web/templates/invite.html +24 -0
  184. synaptane/web/templates/login.html +43 -0
  185. synaptane/web/templates/members-table.html +41 -0
  186. synaptane/web/templates/org-ai.html +94 -0
  187. synaptane/web/templates/org-base.html +27 -0
  188. synaptane/web/templates/org-embeddings.html +32 -0
  189. synaptane/web/templates/org-members.html +52 -0
  190. synaptane/web/templates/org-new-space.html +20 -0
  191. synaptane/web/templates/org-settings.html +17 -0
  192. synaptane/web/templates/org-spaces.html +25 -0
  193. synaptane/web/templates/org-webhooks.html +36 -0
  194. synaptane/web/templates/provider-help.html +55 -0
  195. synaptane/web/templates/public-card.html +9 -0
  196. synaptane/web/templates/reset.html +16 -0
  197. synaptane/web/templates/routine-ui.html +27 -0
  198. synaptane/web/templates/search.html +36 -0
  199. synaptane/web/templates/space-activity.html +33 -0
  200. synaptane/web/templates/space-compare.html +10 -0
  201. synaptane/web/templates/space-general.html +18 -0
  202. synaptane/web/templates/space-git.html +127 -0
  203. synaptane/web/templates/space-header.html +9 -0
  204. synaptane/web/templates/space-health.html +85 -0
  205. synaptane/web/templates/space-members.html +12 -0
  206. synaptane/web/templates/space-routine-form.html +57 -0
  207. synaptane/web/templates/space-routine.html +92 -0
  208. synaptane/web/templates/space-routines.html +70 -0
  209. synaptane/web/templates/space-semantic.html +13 -0
  210. synaptane/web/templates/space-settings-base.html +24 -0
  211. synaptane/web/templates/space-sidebar.html +38 -0
  212. synaptane/web/templates/space-tabs.html +21 -0
  213. synaptane/web/templates/space-tidy-settings.html +29 -0
  214. synaptane/web/templates/space-tree-fragment.html +2 -0
  215. synaptane/web/templates/space-tree.html +22 -0
  216. synaptane/web/templates/space-vocation.html +93 -0
  217. synaptane/web/templates/theme-control.html +2 -0
  218. synaptane/web/templates/tidy-metrics.html +28 -0
  219. synaptane/web/templates/tidy-run-now.html +8 -0
  220. synaptane/web/templates/tidy.html +139 -0
  221. synaptane/web/templates/ui.html +90 -0
  222. synaptane/web/templates/verify-email.html +22 -0
  223. synaptane/web/templates/wiki-related.html +28 -0
  224. synaptane/web/templates/wiki.html +254 -0
  225. synaptane/web/tidy_pages.py +395 -0
  226. synaptane/web/vocation_pages.py +213 -0
  227. synaptane/web/wiki.py +790 -0
  228. synaptane/worker/__init__.py +1 -0
  229. synaptane/worker/runner.py +382 -0
  230. synaptane-1.0.0rc2.dist-info/METADATA +356 -0
  231. synaptane-1.0.0rc2.dist-info/RECORD +236 -0
  232. synaptane-1.0.0rc2.dist-info/WHEEL +4 -0
  233. synaptane-1.0.0rc2.dist-info/entry_points.txt +2 -0
  234. synaptane-1.0.0rc2.dist-info/licenses/LICENSE +202 -0
  235. synaptane-1.0.0rc2.dist-info/licenses/NOTICE +8 -0
  236. 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
@@ -0,0 +1,3 @@
1
+ from synaptane.cli import main
2
+
3
+ raise SystemExit(main())
@@ -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
+ ...