livequill 0.2.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.
- livequill/__init__.py +3 -0
- livequill/__main__.py +5 -0
- livequill/adapters/__init__.py +65 -0
- livequill/adapters/anthropic.py +128 -0
- livequill/adapters/base.py +88 -0
- livequill/adapters/cli.py +245 -0
- livequill/adapters/gemini.py +113 -0
- livequill/adapters/ollama.py +103 -0
- livequill/adapters/openai.py +160 -0
- livequill/agent.py +446 -0
- livequill/cli.py +56 -0
- livequill/config.py +137 -0
- livequill/net.py +61 -0
- livequill/sandbox.py +590 -0
- livequill/server.py +876 -0
- livequill/static/hub.html +241 -0
- livequill/static/overlay.js +485 -0
- livequill/static/setup.html +603 -0
- livequill-0.2.0.dist-info/METADATA +285 -0
- livequill-0.2.0.dist-info/RECORD +24 -0
- livequill-0.2.0.dist-info/WHEEL +5 -0
- livequill-0.2.0.dist-info/entry_points.txt +2 -0
- livequill-0.2.0.dist-info/licenses/LICENSE +21 -0
- livequill-0.2.0.dist-info/top_level.txt +1 -0
livequill/__init__.py
ADDED
livequill/__main__.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Registre des fournisseurs. Ajouter un fournisseur = écrire un adaptateur et l'inscrire dans KINDS."""
|
|
2
|
+
from .anthropic import AnthropicAdapter
|
|
3
|
+
from .base import Adapter, ToolsUnsupported
|
|
4
|
+
from .cli import CLIAdapter
|
|
5
|
+
from .gemini import GeminiAdapter
|
|
6
|
+
from .ollama import OllamaAdapter
|
|
7
|
+
from .openai import CustomAdapter, LlamaCppAdapter, LMStudioAdapter, MistralAdapter, OpenAIAdapter, OpenRouterAdapter
|
|
8
|
+
|
|
9
|
+
# group : « cloud », « local » ou « cli » (rangement de la page de setup)
|
|
10
|
+
# fields : champs du formulaire — key (clé API), base_url, model (nom libre), extra_models, num_ctx
|
|
11
|
+
KINDS = {
|
|
12
|
+
# --- API cloud : url = page où créer la clé --------------------------------------------------------
|
|
13
|
+
'anthropic': {'label': 'Anthropic', 'group': 'cloud', 'adapter': AnthropicAdapter, 'fields': ['key'], 'env': 'ANTHROPIC_API_KEY',
|
|
14
|
+
'url': 'https://console.anthropic.com/settings/keys', 'url_label': 'Créer une clé',
|
|
15
|
+
'help': 'Clé sur console.anthropic.com → Settings → API keys'},
|
|
16
|
+
'openai': {'label': 'OpenAI', 'group': 'cloud', 'adapter': OpenAIAdapter, 'fields': ['key'], 'env': 'OPENAI_API_KEY',
|
|
17
|
+
'url': 'https://platform.openai.com/api-keys', 'url_label': 'Créer une clé',
|
|
18
|
+
'help': 'Clé sur platform.openai.com → API keys'},
|
|
19
|
+
'gemini': {'label': 'Google Gemini', 'group': 'cloud', 'adapter': GeminiAdapter, 'fields': ['key'], 'env': 'GEMINI_API_KEY',
|
|
20
|
+
'url': 'https://aistudio.google.com/apikey', 'url_label': 'Créer une clé',
|
|
21
|
+
'help': 'Clé sur aistudio.google.com → Get API key'},
|
|
22
|
+
'mistral': {'label': 'Mistral', 'group': 'cloud', 'adapter': MistralAdapter, 'fields': ['key'], 'env': 'MISTRAL_API_KEY',
|
|
23
|
+
'url': 'https://console.mistral.ai/api-keys', 'url_label': 'Créer une clé',
|
|
24
|
+
'help': 'Clé sur console.mistral.ai → API keys'},
|
|
25
|
+
'openrouter': {'label': 'OpenRouter', 'group': 'cloud', 'adapter': OpenRouterAdapter, 'fields': ['key'], 'env': 'OPENROUTER_API_KEY',
|
|
26
|
+
'url': 'https://openrouter.ai/keys', 'url_label': 'Créer une clé',
|
|
27
|
+
'help': 'Clé sur openrouter.ai/keys — donne accès à des centaines de modèles'},
|
|
28
|
+
# --- modèles locaux : url = page de téléchargement ; « Autre » pour tout serveur compatible OpenAI ----
|
|
29
|
+
'ollama': {'label': 'Ollama', 'group': 'local', 'adapter': OllamaAdapter, 'fields': ['base_url', 'num_ctx'],
|
|
30
|
+
'url': 'https://ollama.com/download', 'url_label': 'Télécharger',
|
|
31
|
+
'help': 'ollama serve, puis ollama pull qwen2.5:7b-instruct (ou un autre modèle qui gère les tools)'},
|
|
32
|
+
'lmstudio': {'label': 'LM Studio', 'group': 'local', 'adapter': LMStudioAdapter, 'fields': ['base_url'],
|
|
33
|
+
'url': 'https://lmstudio.ai', 'url_label': 'Télécharger',
|
|
34
|
+
'help': 'Onglet Developer → Start server (port 1234 par défaut)'},
|
|
35
|
+
'llamacpp': {'label': 'llama.cpp server', 'group': 'local', 'adapter': LlamaCppAdapter, 'fields': ['base_url'],
|
|
36
|
+
'url': 'https://github.com/ggml-org/llama.cpp', 'url_label': 'Installer',
|
|
37
|
+
'help': 'llama-server -m modele.gguf --jinja (port 8080 par défaut, --jinja active les tools)'},
|
|
38
|
+
'custom': {'label': 'Autre', 'group': 'local', 'adapter': CustomAdapter, 'fields': ['base_url', 'key', 'model'], 'multi': True,
|
|
39
|
+
'help': 'N’importe quel serveur compatible OpenAI (/v1/chat/completions) : vLLM, LocalAI, Jan, Groq, DeepSeek, un proxy maison…'},
|
|
40
|
+
# --- agents CLI : login = commande pour se connecter au compte du fournisseur ------------------------
|
|
41
|
+
'cli-claude': {'label': 'Claude Code (CLI)', 'group': 'cli', 'adapter': CLIAdapter, 'command': 'claude', 'fields': ['extra_models'],
|
|
42
|
+
'url': 'https://code.claude.com/docs', 'url_label': 'Installer', 'login': 'claude, puis /login',
|
|
43
|
+
'help': 'Utilise « claude -p » dans le dossier du projet, avec ton compte Claude Code (CLI, ou celle intégrée à l’app Claude Desktop)'},
|
|
44
|
+
'cli-codex': {'label': 'Codex (CLI)', 'group': 'cli', 'adapter': CLIAdapter, 'command': 'codex', 'fields': ['extra_models'],
|
|
45
|
+
'url': 'https://github.com/openai/codex', 'url_label': 'Installer', 'login': 'codex login',
|
|
46
|
+
'help': 'Utilise « codex exec » dans le dossier du projet, avec ton compte (CLI, ou celle intégrée à l’app ChatGPT ou Codex). Écriture limitée au projet, mais Codex peut lire tout ton disque'},
|
|
47
|
+
'cli-gemini': {'label': 'Gemini CLI', 'group': 'cli', 'adapter': CLIAdapter, 'command': 'gemini', 'fields': ['extra_models'],
|
|
48
|
+
'url': 'https://github.com/google-gemini/gemini-cli', 'url_label': 'Installer', 'login': 'gemini (connexion au 1er lancement)',
|
|
49
|
+
'help': 'Utilise « gemini -p » dans le dossier du projet'},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def make(conf, transport=None):
|
|
54
|
+
kind = KINDS[conf['type']]
|
|
55
|
+
conf = dict(conf)
|
|
56
|
+
if 'command' in kind:
|
|
57
|
+
conf.setdefault('command', kind['command'])
|
|
58
|
+
return kind['adapter'](conf, transport)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def public_kinds():
|
|
62
|
+
return {k: {x: v for x, v in d.items() if x != 'adapter'} for k, d in KINDS.items()}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
__all__ = ['Adapter', 'KINDS', 'ToolsUnsupported', 'make', 'public_kinds']
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Anthropic Messages API (HTTP brut, en flux SSE)."""
|
|
2
|
+
import json, re
|
|
3
|
+
|
|
4
|
+
from ..net import sse
|
|
5
|
+
from .base import Adapter, levels
|
|
6
|
+
|
|
7
|
+
VERSION = '2023-06-01'
|
|
8
|
+
FAST_MODELS = {'claude-opus-5-5', 'claude-opus-5', 'claude-opus-4-8'}
|
|
9
|
+
# Modèles qui acceptent fallbacks: "default" (re-routage côté serveur en cas de refus)
|
|
10
|
+
FALLBACK_MODELS = {'claude-fable-5-1', 'claude-opus-5-5', 'claude-opus-5', 'claude-sonnet-5-5'}
|
|
11
|
+
KNOWN = [('claude-opus-5-5', 'Opus 5.5'), ('claude-sonnet-5-5', 'Sonnet 5.5'), ('claude-haiku-5-5', 'Haiku 5.5'),
|
|
12
|
+
('claude-fable-5-1', 'Fable 5.1')]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def effort_levels(model):
|
|
16
|
+
if re.match(r'claude-(opus|sonnet|haiku|fable|mythos)-5', model) or re.match(r'claude-opus-4-[78]', model):
|
|
17
|
+
return levels('low', 'medium', 'high', 'xhigh', 'max')
|
|
18
|
+
if re.match(r'claude-(opus|sonnet)-4-6', model):
|
|
19
|
+
return levels('low', 'medium', 'high', 'max')
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AnthropicAdapter(Adapter):
|
|
24
|
+
kind = 'anthropic'
|
|
25
|
+
default_base = 'https://api.anthropic.com'
|
|
26
|
+
|
|
27
|
+
def headers(self, betas=()):
|
|
28
|
+
h = {'x-api-key': self.key, 'anthropic-version': VERSION}
|
|
29
|
+
if betas:
|
|
30
|
+
h['anthropic-beta'] = ','.join(betas)
|
|
31
|
+
return h
|
|
32
|
+
|
|
33
|
+
def list_models(self):
|
|
34
|
+
data = self.http.json('GET', f'{self.base}/v1/models?limit=100', self.headers())
|
|
35
|
+
out = [{'id': m['id'], 'label': m.get('display_name') or m['id'], 'meta': {}} for m in data.get('data', [])]
|
|
36
|
+
return out or [{'id': i, 'label': l, 'meta': {}} for i, l in KNOWN]
|
|
37
|
+
|
|
38
|
+
def caps(self, model, meta=None):
|
|
39
|
+
return {'tools': True, 'vision': True, 'effort': effort_levels(model), 'fast': model in FAST_MODELS}
|
|
40
|
+
|
|
41
|
+
def convert(self, messages):
|
|
42
|
+
out = []
|
|
43
|
+
for m in messages:
|
|
44
|
+
if m['role'] == 'tool':
|
|
45
|
+
blocks = [{'type': 'tool_result', 'tool_use_id': r['id'], 'content': r['content'] or '(vide)',
|
|
46
|
+
**({'is_error': True} if r.get('is_error') else {})} for r in m['results']]
|
|
47
|
+
out.append({'role': 'user', 'content': blocks})
|
|
48
|
+
continue
|
|
49
|
+
blocks = []
|
|
50
|
+
for p in m['content']:
|
|
51
|
+
t = p['type']
|
|
52
|
+
if t == 'text' and p['text']:
|
|
53
|
+
blocks.append({'type': 'text', 'text': p['text']})
|
|
54
|
+
elif t == 'image':
|
|
55
|
+
blocks.append({'type': 'image', 'source': {'type': 'base64', 'media_type': p['media_type'], 'data': p['data']}})
|
|
56
|
+
elif t == 'tool_call':
|
|
57
|
+
blocks.append({'type': 'tool_use', 'id': p['id'], 'name': p['name'], 'input': p['args']})
|
|
58
|
+
elif t == 'raw' and p.get('provider') == self.kind:
|
|
59
|
+
blocks.append(p['block']) # blocs de réflexion renvoyés tels quels pendant la boucle
|
|
60
|
+
out.append({'role': m['role'], 'content': blocks or [{'type': 'text', 'text': '…'}]})
|
|
61
|
+
return out
|
|
62
|
+
|
|
63
|
+
def build_request(self, model, system, messages, tools, opts):
|
|
64
|
+
body = {'model': model, 'max_tokens': 32000, 'stream': True, 'system': system, 'messages': self.convert(messages)}
|
|
65
|
+
if tools:
|
|
66
|
+
body['tools'] = [{'name': t['name'], 'description': t['description'], 'input_schema': t['parameters']} for t in tools]
|
|
67
|
+
betas = []
|
|
68
|
+
if effort_levels(model):
|
|
69
|
+
body['thinking'] = {'type': 'adaptive', 'display': 'summarized'}
|
|
70
|
+
if opts.get('effort') in dict(effort_levels(model)):
|
|
71
|
+
body['output_config'] = {'effort': opts['effort']}
|
|
72
|
+
if opts.get('fast') and model in FAST_MODELS:
|
|
73
|
+
body['speed'] = 'fast'
|
|
74
|
+
betas.append('fast-mode-2026-02-01')
|
|
75
|
+
elif model in FALLBACK_MODELS and self.conf.get('fallbacks', True):
|
|
76
|
+
body['fallbacks'] = 'default'
|
|
77
|
+
betas.append('server-side-fallback-2026-07-01')
|
|
78
|
+
return 'POST', f'{self.base}/v1/messages', self.headers(betas), body
|
|
79
|
+
|
|
80
|
+
def parse_stream(self, lines):
|
|
81
|
+
blocks, stop = {}, 'end'
|
|
82
|
+
for _event, data in sse(lines):
|
|
83
|
+
try:
|
|
84
|
+
ev = json.loads(data)
|
|
85
|
+
except ValueError:
|
|
86
|
+
continue
|
|
87
|
+
t = ev.get('type')
|
|
88
|
+
if t == 'error':
|
|
89
|
+
raise RuntimeError(ev.get('error', {}).get('message') or data)
|
|
90
|
+
if t == 'content_block_start':
|
|
91
|
+
b = dict(ev['content_block'])
|
|
92
|
+
if b.get('type') == 'tool_use':
|
|
93
|
+
b['_json'] = ''
|
|
94
|
+
blocks[ev['index']] = b
|
|
95
|
+
elif t == 'content_block_delta':
|
|
96
|
+
b, d = blocks.get(ev['index']), ev['delta']
|
|
97
|
+
if b is None:
|
|
98
|
+
continue
|
|
99
|
+
dt = d.get('type')
|
|
100
|
+
if dt == 'text_delta':
|
|
101
|
+
b['text'] = b.get('text', '') + d['text']
|
|
102
|
+
yield {'type': 'delta', 'text': d['text']}
|
|
103
|
+
elif dt == 'thinking_delta':
|
|
104
|
+
b['thinking'] = b.get('thinking', '') + d['thinking']
|
|
105
|
+
yield {'type': 'thinking', 'text': d['thinking']}
|
|
106
|
+
elif dt == 'signature_delta':
|
|
107
|
+
b['signature'] = b.get('signature', '') + d['signature']
|
|
108
|
+
elif dt == 'input_json_delta':
|
|
109
|
+
b['_json'] += d.get('partial_json', '')
|
|
110
|
+
elif t == 'content_block_stop':
|
|
111
|
+
b = blocks.pop(ev['index'], None)
|
|
112
|
+
if not b:
|
|
113
|
+
continue
|
|
114
|
+
if b['type'] == 'text':
|
|
115
|
+
yield {'type': 'part', 'part': {'type': 'text', 'text': b.get('text', '')}}
|
|
116
|
+
elif b['type'] == 'tool_use':
|
|
117
|
+
try:
|
|
118
|
+
args = json.loads(b['_json'] or '{}')
|
|
119
|
+
except ValueError:
|
|
120
|
+
args = {'_invalid_json': b['_json']}
|
|
121
|
+
yield {'type': 'part', 'part': {'type': 'tool_call', 'id': b['id'], 'name': b['name'], 'args': args}}
|
|
122
|
+
else: # thinking, redacted_thinking, fallback… : renvoyés tels quels au tour suivant
|
|
123
|
+
yield {'type': 'part', 'part': {'type': 'raw', 'provider': self.kind, 'block': b}}
|
|
124
|
+
elif t == 'message_delta':
|
|
125
|
+
r = ev.get('delta', {}).get('stop_reason')
|
|
126
|
+
if r:
|
|
127
|
+
stop = {'tool_use': 'tool_use', 'max_tokens': 'max_tokens', 'refusal': 'refusal'}.get(r, 'end')
|
|
128
|
+
yield {'type': 'stop', 'reason': stop}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Interface commune des adaptateurs.
|
|
2
|
+
|
|
3
|
+
Format interne des messages (indépendant du fournisseur) :
|
|
4
|
+
{"role": "user", "content": [{"type": "text", "text": ...}, {"type": "image", "media_type": ..., "data": <base64>}]}
|
|
5
|
+
{"role": "assistant", "content": [{"type": "text", "text": ...},
|
|
6
|
+
{"type": "tool_call", "id": ..., "name": ..., "args": {...}, "meta": {...}},
|
|
7
|
+
{"type": "raw", "provider": "anthropic", "block": {...}}]}
|
|
8
|
+
{"role": "tool", "results": [{"id": ..., "name": ..., "content": str, "is_error": bool}]}
|
|
9
|
+
|
|
10
|
+
Un adaptateur transforme ce format en requête HTTP (build_request, pure donc testable),
|
|
11
|
+
puis lit le flux de réponse (parse_stream) et produit des événements :
|
|
12
|
+
{"type": "delta", "text": ...} texte affiché en direct
|
|
13
|
+
{"type": "thinking", "text": ...} réflexion (si le fournisseur la renvoie)
|
|
14
|
+
{"type": "part", "part": {...}} bloc terminé, ajouté au message assistant
|
|
15
|
+
{"type": "stop", "reason": "end" | "tool_use" | "max_tokens" | "refusal"}
|
|
16
|
+
"""
|
|
17
|
+
from ..config import api_key
|
|
18
|
+
from ..net import HTTPError, Transport
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ToolsUnsupported(Exception):
|
|
22
|
+
"""Le modèle refuse les tool calls : l'agent repasse en mode texte."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Adapter:
|
|
26
|
+
kind = '' # identifiant du type de fournisseur
|
|
27
|
+
is_cli = False # True : l'agent CLI fait lui-même la boucle et écrit les fichiers
|
|
28
|
+
default_base = ''
|
|
29
|
+
|
|
30
|
+
def __init__(self, conf, transport=None):
|
|
31
|
+
self.conf = conf
|
|
32
|
+
self.http = transport or Transport()
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def base(self):
|
|
36
|
+
return (self.conf.get('base_url') or self.default_base).rstrip('/')
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def key(self):
|
|
40
|
+
return api_key(self.conf)
|
|
41
|
+
|
|
42
|
+
# --- à fournir par chaque adaptateur ---------------------------------
|
|
43
|
+
def list_models(self):
|
|
44
|
+
"""[{"id": str, "label": str, "meta": {...}}] — meta sert à calculer les capacités."""
|
|
45
|
+
raise NotImplementedError
|
|
46
|
+
|
|
47
|
+
def caps(self, model, meta=None):
|
|
48
|
+
"""{"tools": bool, "vision": bool, "effort": [[valeur, libellé], ...] | None, "fast": bool}"""
|
|
49
|
+
return {'tools': True, 'vision': False, 'effort': None, 'fast': False}
|
|
50
|
+
|
|
51
|
+
def build_request(self, model, system, messages, tools, opts):
|
|
52
|
+
"""-> (méthode, url, en-têtes, corps JSON)"""
|
|
53
|
+
raise NotImplementedError
|
|
54
|
+
|
|
55
|
+
def parse_stream(self, lines):
|
|
56
|
+
raise NotImplementedError
|
|
57
|
+
|
|
58
|
+
# --- commun ----------------------------------------------------------
|
|
59
|
+
def test(self):
|
|
60
|
+
models = self.list_models()
|
|
61
|
+
return f'{len(models)} modèle(s) disponible(s)', models
|
|
62
|
+
|
|
63
|
+
def chat(self, model, system, messages, tools, opts, job=None):
|
|
64
|
+
method, url, headers, body = self.build_request(model, system, messages, tools, opts)
|
|
65
|
+
try:
|
|
66
|
+
yield from self.parse_stream(self.http.lines(method, url, headers, body, job))
|
|
67
|
+
except HTTPError as e:
|
|
68
|
+
if tools and looks_like_no_tools(e.body):
|
|
69
|
+
raise ToolsUnsupported(e.body) from None
|
|
70
|
+
raise
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def looks_like_no_tools(body):
|
|
74
|
+
b = (body or '').lower()
|
|
75
|
+
return 'does not support tools' in b or 'tool use is not supported' in b or ('tools' in b and 'not supported' in b) \
|
|
76
|
+
or 'function calling is not enabled' in b
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
EFFORT_LABELS = {'off': 'Désactivée', 'on': 'Activée', 'minimal': 'Minimal', 'low': 'Low', 'medium': 'Medium',
|
|
80
|
+
'high': 'High', 'xhigh': 'Extra high', 'max': 'Max'}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def levels(*vals):
|
|
84
|
+
return [[v, EFFORT_LABELS.get(v, v)] for v in vals]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def text_of(content):
|
|
88
|
+
return ''.join(p.get('text', '') for p in content if p.get('type') == 'text')
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Agents CLI déjà installés (Claude Code, Codex, Gemini CLI) : ils font leur propre boucle et écrivent les fichiers.
|
|
2
|
+
|
|
3
|
+
Pont vers les applications desktop : Claude Desktop embarque Claude Code, ChatGPT (et l'app Codex) embarquent Codex.
|
|
4
|
+
Sans CLI installée, on utilise l'agent de l'application, déjà connecté au compte de l'utilisateur."""
|
|
5
|
+
import glob, json, os, re, shutil, subprocess
|
|
6
|
+
|
|
7
|
+
from .base import Adapter, levels
|
|
8
|
+
|
|
9
|
+
AGENTS = {
|
|
10
|
+
'claude': {'label': 'Claude Code', 'models': [('default', 'Par défaut (Claude Code)'), ('opus', 'Opus'), ('sonnet', 'Sonnet'), ('haiku', 'Haiku')],
|
|
11
|
+
'effort': levels('low', 'medium', 'high', 'xhigh', 'max')},
|
|
12
|
+
'codex': {'label': 'Codex', 'models': [('default', 'Par défaut (Codex)')], 'effort': levels('low', 'medium', 'high')},
|
|
13
|
+
'gemini': {'label': 'Gemini CLI', 'models': [('default', 'Par défaut (Gemini CLI)')], 'effort': None},
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Où les installeurs officiels posent ces CLI, au cas où LiveQuill est lancé avec un PATH réduit
|
|
18
|
+
# (raccourci de bureau, service, autre session) qui ne contient pas ces dossiers.
|
|
19
|
+
EXTRA_DIRS = ['~/.local/bin', '~/bin', '~/.npm-global/bin', '~/.bun/bin', '~/.volta/bin', '~/.cargo/bin',
|
|
20
|
+
'~/.claude/local', '/usr/local/bin', '/opt/homebrew/bin']
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Agents embarqués dans les applications desktop (Linux puis macOS). Le dossier de version change à chaque mise à jour
|
|
24
|
+
# de l'application : on cherche à chaque fois et on prend la version la plus récente.
|
|
25
|
+
BUNDLED = {
|
|
26
|
+
'claude': [('Claude Desktop', '~/.config/Claude/claude-code/*/*/claude'),
|
|
27
|
+
('Claude Desktop', '~/Library/Application Support/Claude/claude-code/*/*/claude')],
|
|
28
|
+
'codex': [('ChatGPT', '/usr/lib/chatgpt/resources/codex'),
|
|
29
|
+
('ChatGPT', '/opt/ChatGPT/resources/codex'),
|
|
30
|
+
('ChatGPT', '/Applications/ChatGPT.app/Contents/Resources/codex'),
|
|
31
|
+
('ChatGPT', '~/Applications/ChatGPT.app/Contents/Resources/codex'),
|
|
32
|
+
('Codex', '/Applications/Codex.app/Contents/Resources/codex'),
|
|
33
|
+
('Codex', '~/Applications/Codex.app/Contents/Resources/codex')],
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _version_key(path):
|
|
38
|
+
return [int(n) for n in re.findall(r'\d+', path)]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def locate(name):
|
|
42
|
+
"""-> (chemin, application) : la CLI installée d'abord, sinon l'agent embarqué dans une application desktop."""
|
|
43
|
+
exe = shutil.which(name)
|
|
44
|
+
if exe:
|
|
45
|
+
return exe, None
|
|
46
|
+
for d in EXTRA_DIRS:
|
|
47
|
+
p = os.path.join(os.path.expanduser(d), name)
|
|
48
|
+
if os.path.isfile(p) and os.access(p, os.X_OK):
|
|
49
|
+
return p, None
|
|
50
|
+
for app, pattern in BUNDLED.get(name, []):
|
|
51
|
+
hits = [p for p in glob.glob(os.path.expanduser(pattern)) if os.path.isfile(p) and os.access(p, os.X_OK)]
|
|
52
|
+
if hits:
|
|
53
|
+
return max(hits, key=_version_key), app
|
|
54
|
+
return None, None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def find(name):
|
|
58
|
+
return locate(name)[0]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def detect():
|
|
62
|
+
return {name: find(name) for name in AGENTS}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def detect_via():
|
|
66
|
+
"""Application desktop qui fournit l'agent, quand ce n'est pas une CLI installée."""
|
|
67
|
+
return {name: locate(name)[1] for name in AGENTS}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def codex_user_model():
|
|
71
|
+
"""Le modèle de premier niveau de ~/.codex/config.toml (seule valeur reprise de la config ignorée)."""
|
|
72
|
+
path = os.path.join(os.environ.get('CODEX_HOME') or os.path.expanduser('~/.codex'), 'config.toml')
|
|
73
|
+
try:
|
|
74
|
+
with open(path, encoding='utf-8') as f:
|
|
75
|
+
for line in f:
|
|
76
|
+
if line.lstrip().startswith('['):
|
|
77
|
+
break # fin des clés de premier niveau
|
|
78
|
+
m = re.match(r'\s*model\s*=\s*"([^"]+)"', line)
|
|
79
|
+
if m:
|
|
80
|
+
return m.group(1)
|
|
81
|
+
except OSError:
|
|
82
|
+
pass
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def logged_in(name, exe):
|
|
87
|
+
"""True / False si l'agent sait le dire, None sinon (vieille version, commande inconnue)."""
|
|
88
|
+
try:
|
|
89
|
+
if name == 'claude':
|
|
90
|
+
out = subprocess.run([exe, 'auth', 'status', '--json'], capture_output=True, text=True, timeout=20)
|
|
91
|
+
return bool(json.loads(out.stdout).get('loggedIn')) if out.stdout.strip().startswith('{') else None
|
|
92
|
+
if name == 'codex':
|
|
93
|
+
out = subprocess.run([exe, 'login', 'status'], capture_output=True, text=True, timeout=20)
|
|
94
|
+
text = (out.stdout + out.stderr).lower()
|
|
95
|
+
return True if 'logged in' in text and 'not logged in' not in text else (False if 'not logged in' in text else None)
|
|
96
|
+
except (OSError, ValueError, subprocess.SubprocessError):
|
|
97
|
+
return None
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class CLIAdapter(Adapter):
|
|
102
|
+
kind = 'cli'
|
|
103
|
+
is_cli = True
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def agent(self):
|
|
107
|
+
return self.conf.get('command', 'claude')
|
|
108
|
+
|
|
109
|
+
def list_models(self):
|
|
110
|
+
a = AGENTS[self.agent]
|
|
111
|
+
extra = [m.strip() for m in (self.conf.get('extra_models') or '').split(',') if m.strip()]
|
|
112
|
+
return [{'id': i, 'label': l, 'meta': {}} for i, l in a['models']] + [{'id': m, 'label': m, 'meta': {}} for m in extra]
|
|
113
|
+
|
|
114
|
+
def caps(self, model, meta=None):
|
|
115
|
+
return {'tools': True, 'vision': self.agent in ('claude', 'codex'), 'effort': AGENTS[self.agent]['effort'], 'fast': False}
|
|
116
|
+
|
|
117
|
+
def test(self):
|
|
118
|
+
exe, app = locate(self.agent)
|
|
119
|
+
label = AGENTS[self.agent]['label']
|
|
120
|
+
if not exe:
|
|
121
|
+
raise RuntimeError(f'« {self.agent} » est introuvable : installe la CLI, ou l’application '
|
|
122
|
+
f'{"Claude Desktop" if self.agent == "claude" else "ChatGPT" if self.agent == "codex" else "officielle"}')
|
|
123
|
+
if logged_in(self.agent, exe) is False:
|
|
124
|
+
how = f'ouvre l’application {app} et connecte-toi' if app else ('lance « claude auth login »' if self.agent == 'claude' else 'lance « codex login »')
|
|
125
|
+
raise RuntimeError(f'{label} n’est pas connecté à ton compte : {how}, puis reteste')
|
|
126
|
+
out = subprocess.run([exe, '--version'], capture_output=True, text=True, timeout=20)
|
|
127
|
+
version = (out.stdout or out.stderr).strip()[:80]
|
|
128
|
+
return f'{label} trouvé{f" (via l’app {app})" if app else ""} : {version}', self.list_models()
|
|
129
|
+
|
|
130
|
+
def command(self, root, model, opts, images, prompt=''):
|
|
131
|
+
"""-> (argv, texte envoyé sur stdin). Le prompt passe par stdin : pas de limite de taille d'argument."""
|
|
132
|
+
exe = find(self.agent) or self.agent
|
|
133
|
+
m = None if model in (None, '', 'default') else model
|
|
134
|
+
e = opts.get('effort')
|
|
135
|
+
if self.agent == 'claude':
|
|
136
|
+
# --restricted : outils de fichiers confinés au dossier du projet, aucun outil qui exécute du code,
|
|
137
|
+
# réglages utilisateur/projet ignorés (hooks compris) ; --strict-mcp-config : aucun serveur MCP de l'utilisateur
|
|
138
|
+
argv = [exe, '-p', '--output-format', 'stream-json', '--verbose', '--include-partial-messages',
|
|
139
|
+
'--restricted', '--strict-mcp-config', '--tools', 'Read,Edit,Write,Glob,Grep',
|
|
140
|
+
'--permission-mode', 'acceptEdits', '--no-session-persistence',
|
|
141
|
+
'--disallowedTools', 'Bash', 'WebFetch', 'WebSearch', 'Read(./.env*)', 'Edit(./.env*)', 'Write(./.env*)',
|
|
142
|
+
'--allowedTools', 'Read', 'Edit', 'Write', 'Glob', 'Grep']
|
|
143
|
+
if m:
|
|
144
|
+
argv += ['--model', m]
|
|
145
|
+
if e:
|
|
146
|
+
argv += ['--effort', e]
|
|
147
|
+
return argv
|
|
148
|
+
if self.agent == 'codex':
|
|
149
|
+
# --ignore-user-config : ni les serveurs MCP ni les dossiers inscriptibles ajoutés dans ~/.codex/config.toml
|
|
150
|
+
# (un serveur MCP tourne hors de la sandbox) ; la connexion au compte reste celle de CODEX_HOME
|
|
151
|
+
# Sans la config utilisateur, on fixe nous-mêmes : jamais d'approbation demandée (impossible en non
|
|
152
|
+
# interactif ; les commandes restent dans la sandbox) et pas de réseau.
|
|
153
|
+
argv = [exe, 'exec', '--json', '--sandbox', 'workspace-write', '--ignore-user-config',
|
|
154
|
+
'-c', 'approval_policy="never"', '-c', 'sandbox_workspace_write.network_access=false',
|
|
155
|
+
'--cd', root, '--skip-git-repo-check', '--ephemeral']
|
|
156
|
+
m = m or codex_user_model() # « Par défaut » garde le modèle choisi dans ~/.codex/config.toml
|
|
157
|
+
if m:
|
|
158
|
+
argv += ['--model', m]
|
|
159
|
+
if e:
|
|
160
|
+
argv += ['-c', f'model_reasoning_effort="{e}"']
|
|
161
|
+
if images:
|
|
162
|
+
argv += ['--image'] + images # en dernier : l'option prend plusieurs valeurs
|
|
163
|
+
return argv
|
|
164
|
+
argv = [exe, '--approval-mode', 'auto_edit']
|
|
165
|
+
if m:
|
|
166
|
+
argv += ['-m', m]
|
|
167
|
+
return argv + ['-p', prompt] # non vérifié : gemini n'est pas installé sur la machine de dev
|
|
168
|
+
|
|
169
|
+
def run(self, job, root, prompt, model, opts, images, emit):
|
|
170
|
+
argv = self.command(root, model, opts, images, prompt)
|
|
171
|
+
env = dict(os.environ, NO_COLOR='1')
|
|
172
|
+
if os.path.isabs(argv[0]): # trouvé hors du PATH : ses outils voisins (node…) doivent rester accessibles
|
|
173
|
+
env['PATH'] = os.pathsep.join([os.path.dirname(argv[0]), env.get('PATH', '')])
|
|
174
|
+
p = subprocess.Popen(argv, cwd=root, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
175
|
+
text=True, bufsize=1, env=env)
|
|
176
|
+
job.on_cancel(p.kill)
|
|
177
|
+
p.stdin.write('' if self.agent == 'gemini' else prompt)
|
|
178
|
+
p.stdin.close()
|
|
179
|
+
final = []
|
|
180
|
+
for ln in p.stdout:
|
|
181
|
+
for ev in self.parse_line(ln.rstrip('\n')):
|
|
182
|
+
if ev['type'] == 'final':
|
|
183
|
+
final = [ev['text']]
|
|
184
|
+
else:
|
|
185
|
+
if ev['type'] == 'delta':
|
|
186
|
+
final.append(ev['text'])
|
|
187
|
+
emit(ev)
|
|
188
|
+
err = p.stderr.read()
|
|
189
|
+
code = p.wait()
|
|
190
|
+
if code and not job.cancelled:
|
|
191
|
+
raise RuntimeError(f'{self.agent} a échoué (code {code}) : {err.strip()[-600:]}')
|
|
192
|
+
return ''.join(final).strip()
|
|
193
|
+
|
|
194
|
+
def parse_line(self, ln):
|
|
195
|
+
if self.agent == 'gemini':
|
|
196
|
+
return [{'type': 'delta', 'text': ln + '\n'}] if ln else []
|
|
197
|
+
try:
|
|
198
|
+
ev = json.loads(ln)
|
|
199
|
+
except ValueError:
|
|
200
|
+
return []
|
|
201
|
+
return (parse_claude if self.agent == 'claude' else parse_codex)(ev)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def tool_summary(name, inp):
|
|
205
|
+
path = inp.get('file_path') or inp.get('path') or inp.get('pattern') or ''
|
|
206
|
+
return {'type': 'tool', 'name': name, 'summary': f'{name} {path}'.strip(), 'path': path}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def parse_claude(ev):
|
|
210
|
+
t = ev.get('type')
|
|
211
|
+
if t == 'stream_event':
|
|
212
|
+
e = ev.get('event') or {}
|
|
213
|
+
d = e.get('delta') or {}
|
|
214
|
+
if e.get('type') == 'content_block_delta' and d.get('type') == 'text_delta':
|
|
215
|
+
return [{'type': 'delta', 'text': d['text']}]
|
|
216
|
+
if e.get('type') == 'content_block_delta' and d.get('type') == 'thinking_delta':
|
|
217
|
+
return [{'type': 'thinking', 'text': d['thinking']}]
|
|
218
|
+
return []
|
|
219
|
+
if t == 'assistant':
|
|
220
|
+
return [tool_summary(b['name'], b.get('input') or {}) for b in (ev.get('message') or {}).get('content', [])
|
|
221
|
+
if b.get('type') == 'tool_use']
|
|
222
|
+
if t == 'result':
|
|
223
|
+
if ev.get('is_error'):
|
|
224
|
+
raise RuntimeError(str(ev.get('result') or ev.get('subtype')))
|
|
225
|
+
return [{'type': 'final', 'text': ev.get('result') or ''}]
|
|
226
|
+
return []
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def parse_codex(ev):
|
|
230
|
+
t = ev.get('type', '')
|
|
231
|
+
if t in ('error', 'turn.failed'):
|
|
232
|
+
raise RuntimeError(str(ev.get('message') or ev.get('error') or ev))
|
|
233
|
+
if t != 'item.completed':
|
|
234
|
+
return []
|
|
235
|
+
it = ev.get('item') or {}
|
|
236
|
+
k = it.get('type') or it.get('item_type')
|
|
237
|
+
if k in ('agent_message', 'assistant_message'):
|
|
238
|
+
return [{'type': 'delta', 'text': (it.get('text') or '') + '\n'}]
|
|
239
|
+
if k == 'reasoning':
|
|
240
|
+
return [{'type': 'thinking', 'text': it.get('text') or ''}]
|
|
241
|
+
if k == 'file_change':
|
|
242
|
+
return [{'type': 'tool', 'name': 'edit', 'summary': f'edit {c.get("path")}', 'path': c.get('path')} for c in it.get('changes') or []]
|
|
243
|
+
if k == 'command_execution':
|
|
244
|
+
return [{'type': 'tool', 'name': 'shell', 'summary': f'$ {it.get("command")}'}]
|
|
245
|
+
return []
|