euron-coding-agent 0.1.0__tar.gz
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.
- euron_coding_agent-0.1.0/LICENSE +21 -0
- euron_coding_agent-0.1.0/PKG-INFO +70 -0
- euron_coding_agent-0.1.0/README.md +42 -0
- euron_coding_agent-0.1.0/euron_agent/__init__.py +9 -0
- euron_coding_agent-0.1.0/euron_agent/__main__.py +7 -0
- euron_coding_agent-0.1.0/euron_agent/cli.py +286 -0
- euron_coding_agent-0.1.0/euron_agent/config.py +203 -0
- euron_coding_agent-0.1.0/euron_agent/events.py +85 -0
- euron_coding_agent-0.1.0/euron_agent/llm.py +272 -0
- euron_coding_agent-0.1.0/euron_agent/loop.py +140 -0
- euron_coding_agent-0.1.0/euron_agent/prompts.py +39 -0
- euron_coding_agent-0.1.0/euron_agent/server.py +216 -0
- euron_coding_agent-0.1.0/euron_agent/tool_schemas.py +109 -0
- euron_coding_agent-0.1.0/euron_agent/tools.py +318 -0
- euron_coding_agent-0.1.0/euron_coding_agent.egg-info/PKG-INFO +70 -0
- euron_coding_agent-0.1.0/euron_coding_agent.egg-info/SOURCES.txt +20 -0
- euron_coding_agent-0.1.0/euron_coding_agent.egg-info/dependency_links.txt +1 -0
- euron_coding_agent-0.1.0/euron_coding_agent.egg-info/entry_points.txt +2 -0
- euron_coding_agent-0.1.0/euron_coding_agent.egg-info/requires.txt +11 -0
- euron_coding_agent-0.1.0/euron_coding_agent.egg-info/top_level.txt +1 -0
- euron_coding_agent-0.1.0/pyproject.toml +42 -0
- euron_coding_agent-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Euron Agent contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: euron-coding-agent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight provider-agnostic agentic coding backend (CLI + server) for the Euron VS Code extension.
|
|
5
|
+
Author: Euron Agent contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/euronone/Euron-coding-agent
|
|
8
|
+
Project-URL: Issues, https://github.com/euronone/Euron-coding-agent/issues
|
|
9
|
+
Keywords: ai,agent,coding,llm,cli,vscode
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: fastapi>=0.110
|
|
18
|
+
Requires-Dist: uvicorn[standard]>=0.29
|
|
19
|
+
Requires-Dist: pydantic>=2.6
|
|
20
|
+
Requires-Dist: python-dotenv>=1.0
|
|
21
|
+
Requires-Dist: pyyaml>=6.0
|
|
22
|
+
Requires-Dist: openai>=1.30
|
|
23
|
+
Requires-Dist: httpx>=0.27
|
|
24
|
+
Requires-Dist: rich>=13.7
|
|
25
|
+
Provides-Extra: anthropic
|
|
26
|
+
Requires-Dist: anthropic>=0.34; extra == "anthropic"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# euron-agent (backend)
|
|
30
|
+
|
|
31
|
+
The Python agent backend behind the **Euron Coding Agent** VS Code extension —
|
|
32
|
+
also a standalone CLI. Provider-agnostic (Euron/Euri, OpenAI, OpenRouter,
|
|
33
|
+
Anthropic, Ollama, or any OpenAI-compatible / self-hosted endpoint).
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install euron-coding-agent # add: pip install "euron-coding-agent[anthropic]" for Claude
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## CLI
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
euron-agent init # scaffold config.yaml + .env (optional)
|
|
45
|
+
euron-agent providers # list providers
|
|
46
|
+
euron-agent run "add a /health route to app.py"
|
|
47
|
+
euron-agent chat # interactive REPL (keeps context)
|
|
48
|
+
euron-agent serve --port 0 # run the API/WebSocket server (0 = auto-port)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Set a key via environment (`OPENAI_API_KEY`, `EURI_API_KEY`, …) or `config.yaml`.
|
|
52
|
+
Built-in provider profiles mean no config file is required — just pick a
|
|
53
|
+
provider and supply a key.
|
|
54
|
+
|
|
55
|
+
## Server
|
|
56
|
+
|
|
57
|
+
- `GET /health` — liveness.
|
|
58
|
+
- `GET /providers` — configured providers.
|
|
59
|
+
- `POST /agent/run` — one-shot, non-interactive (set `"auto_approve": true`).
|
|
60
|
+
- `WS /ws` — streaming agent with per-action approval (used by the extension).
|
|
61
|
+
|
|
62
|
+
The WebSocket `init` message accepts `provider`, `model`, `api_key`, and
|
|
63
|
+
`base_url` so secrets never need to live on disk.
|
|
64
|
+
|
|
65
|
+
See the [project README](https://github.com/euronone/Euron-coding-agent) for the
|
|
66
|
+
full architecture and the VS Code extension.
|
|
67
|
+
|
|
68
|
+
## License
|
|
69
|
+
|
|
70
|
+
MIT
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# euron-agent (backend)
|
|
2
|
+
|
|
3
|
+
The Python agent backend behind the **Euron Coding Agent** VS Code extension —
|
|
4
|
+
also a standalone CLI. Provider-agnostic (Euron/Euri, OpenAI, OpenRouter,
|
|
5
|
+
Anthropic, Ollama, or any OpenAI-compatible / self-hosted endpoint).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install euron-coding-agent # add: pip install "euron-coding-agent[anthropic]" for Claude
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## CLI
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
euron-agent init # scaffold config.yaml + .env (optional)
|
|
17
|
+
euron-agent providers # list providers
|
|
18
|
+
euron-agent run "add a /health route to app.py"
|
|
19
|
+
euron-agent chat # interactive REPL (keeps context)
|
|
20
|
+
euron-agent serve --port 0 # run the API/WebSocket server (0 = auto-port)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Set a key via environment (`OPENAI_API_KEY`, `EURI_API_KEY`, …) or `config.yaml`.
|
|
24
|
+
Built-in provider profiles mean no config file is required — just pick a
|
|
25
|
+
provider and supply a key.
|
|
26
|
+
|
|
27
|
+
## Server
|
|
28
|
+
|
|
29
|
+
- `GET /health` — liveness.
|
|
30
|
+
- `GET /providers` — configured providers.
|
|
31
|
+
- `POST /agent/run` — one-shot, non-interactive (set `"auto_approve": true`).
|
|
32
|
+
- `WS /ws` — streaming agent with per-action approval (used by the extension).
|
|
33
|
+
|
|
34
|
+
The WebSocket `init` message accepts `provider`, `model`, `api_key`, and
|
|
35
|
+
`base_url` so secrets never need to live on disk.
|
|
36
|
+
|
|
37
|
+
See the [project README](https://github.com/euronone/Euron-coding-agent) for the
|
|
38
|
+
full architecture and the VS Code extension.
|
|
39
|
+
|
|
40
|
+
## License
|
|
41
|
+
|
|
42
|
+
MIT
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Command-line interface.
|
|
2
|
+
|
|
3
|
+
euron-agent run "add a /health route to app.py" # one-shot in cwd
|
|
4
|
+
euron-agent chat # interactive REPL
|
|
5
|
+
euron-agent serve # start the API server
|
|
6
|
+
euron-agent providers # list configured providers
|
|
7
|
+
euron-agent init # scaffold config.yaml/.env
|
|
8
|
+
|
|
9
|
+
The CLI uses the very same AgentSession/loop as the VS Code backend, so the
|
|
10
|
+
terminal experience and the editor experience are identical.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import asyncio
|
|
16
|
+
import os
|
|
17
|
+
import shutil
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from rich.console import Console
|
|
22
|
+
from rich.panel import Panel
|
|
23
|
+
from rich.prompt import Prompt
|
|
24
|
+
|
|
25
|
+
from .config import load_config
|
|
26
|
+
from .events import AgentIO, ApprovalDecision
|
|
27
|
+
from .loop import AgentSession
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _force_utf8() -> None:
|
|
31
|
+
"""Avoid UnicodeEncodeError for box-drawing/emoji on Windows code pages."""
|
|
32
|
+
for stream in (sys.stdout, sys.stderr):
|
|
33
|
+
try:
|
|
34
|
+
stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
|
|
35
|
+
except Exception:
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_force_utf8()
|
|
40
|
+
# legacy_windows=False -> use ANSI (Windows 10+ supports it) instead of the
|
|
41
|
+
# win32 console API, which encodes with the active code page and chokes on '●'.
|
|
42
|
+
console = Console(legacy_windows=False)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# --------------------------------------------------------------------------- #
|
|
46
|
+
# Terminal IO
|
|
47
|
+
# --------------------------------------------------------------------------- #
|
|
48
|
+
class TerminalIO(AgentIO):
|
|
49
|
+
def __init__(self, auto_approve: bool):
|
|
50
|
+
self.auto_approve = auto_approve
|
|
51
|
+
self._dirty = False # unfinished streamed line on screen
|
|
52
|
+
|
|
53
|
+
def _newline_if_dirty(self) -> None:
|
|
54
|
+
if self._dirty:
|
|
55
|
+
sys.stdout.write("\n")
|
|
56
|
+
sys.stdout.flush()
|
|
57
|
+
self._dirty = False
|
|
58
|
+
|
|
59
|
+
# streamed tokens (may arrive from a worker thread)
|
|
60
|
+
def on_token(self, text: str) -> None:
|
|
61
|
+
sys.stdout.write(text)
|
|
62
|
+
sys.stdout.flush()
|
|
63
|
+
self._dirty = True
|
|
64
|
+
|
|
65
|
+
async def emit(self, event: dict) -> None:
|
|
66
|
+
t = event["type"]
|
|
67
|
+
if t == "status":
|
|
68
|
+
return # keep the terminal quiet; spinners would fight streaming
|
|
69
|
+
if t == "assistant_message":
|
|
70
|
+
# text already streamed via tokens; just close the line
|
|
71
|
+
self._newline_if_dirty()
|
|
72
|
+
return
|
|
73
|
+
self._newline_if_dirty()
|
|
74
|
+
if t == "tool_start":
|
|
75
|
+
args = event["args"]
|
|
76
|
+
detail = args.get("path") or args.get("command") or args.get("query") or ""
|
|
77
|
+
console.print(f"[cyan]⚙ {event['name']}[/cyan] [dim]{detail}[/dim]")
|
|
78
|
+
elif t == "diff":
|
|
79
|
+
self._print_diff(event["patch"])
|
|
80
|
+
elif t == "tool_result":
|
|
81
|
+
mark = "[green]✓[/green]" if event["ok"] else "[red]✗[/red]"
|
|
82
|
+
out = (event["output"] or "").strip()
|
|
83
|
+
if out:
|
|
84
|
+
snippet = out if len(out) < 1200 else out[:1200] + " …"
|
|
85
|
+
console.print(f"{mark} [dim]{snippet}[/dim]")
|
|
86
|
+
elif t == "error":
|
|
87
|
+
console.print(f"[red]error:[/red] {event['message']}")
|
|
88
|
+
elif t == "done":
|
|
89
|
+
console.print("[dim]— done —[/dim]")
|
|
90
|
+
|
|
91
|
+
def _print_diff(self, patch: str) -> None:
|
|
92
|
+
for line in patch.splitlines():
|
|
93
|
+
if line.startswith("+") and not line.startswith("+++"):
|
|
94
|
+
console.print(f"[green]{line}[/green]")
|
|
95
|
+
elif line.startswith("-") and not line.startswith("---"):
|
|
96
|
+
console.print(f"[red]{line}[/red]")
|
|
97
|
+
elif line.startswith("@@"):
|
|
98
|
+
console.print(f"[magenta]{line}[/magenta]")
|
|
99
|
+
else:
|
|
100
|
+
console.print(f"[dim]{line}[/dim]")
|
|
101
|
+
|
|
102
|
+
async def request_approval(self, request: dict) -> ApprovalDecision:
|
|
103
|
+
self._newline_if_dirty()
|
|
104
|
+
preview = request.get("preview") or ""
|
|
105
|
+
title = f"Approve {request['name']}?"
|
|
106
|
+
if preview:
|
|
107
|
+
self._print_diff(preview) if "\n" in preview and (
|
|
108
|
+
"+++" in preview or "@@" in preview
|
|
109
|
+
) else console.print(Panel(preview, title=title, border_style="yellow"))
|
|
110
|
+
if self.auto_approve:
|
|
111
|
+
console.print("[green]auto-approved[/green]")
|
|
112
|
+
return ApprovalDecision(approved=True)
|
|
113
|
+
|
|
114
|
+
answer = await asyncio.to_thread(
|
|
115
|
+
Prompt.ask,
|
|
116
|
+
f"[yellow]{title}[/yellow] (y/n, or type feedback to reject)",
|
|
117
|
+
default="y",
|
|
118
|
+
)
|
|
119
|
+
a = answer.strip().lower()
|
|
120
|
+
if a in ("y", "yes", ""):
|
|
121
|
+
return ApprovalDecision(approved=True)
|
|
122
|
+
if a in ("n", "no"):
|
|
123
|
+
return ApprovalDecision(approved=False, feedback="rejected by user")
|
|
124
|
+
return ApprovalDecision(approved=False, feedback=answer.strip())
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# --------------------------------------------------------------------------- #
|
|
128
|
+
# Commands
|
|
129
|
+
# --------------------------------------------------------------------------- #
|
|
130
|
+
async def _run_task(task: str, args) -> None:
|
|
131
|
+
cfg = load_config(args.config, provider=args.provider, model=args.model)
|
|
132
|
+
if args.yes:
|
|
133
|
+
cfg.agent.auto_approve_writes = True
|
|
134
|
+
cfg.agent.auto_approve_commands = True
|
|
135
|
+
workspace = str(Path(args.workspace).resolve())
|
|
136
|
+
console.print(
|
|
137
|
+
f"[dim]workspace={workspace} · provider={cfg.provider.name} · "
|
|
138
|
+
f"model={cfg.provider.model}[/dim]"
|
|
139
|
+
)
|
|
140
|
+
io = TerminalIO(auto_approve=args.yes)
|
|
141
|
+
session = AgentSession(workspace, cfg, io)
|
|
142
|
+
await session.run(task)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def cmd_run(args) -> None:
|
|
146
|
+
asyncio.run(_run_task(args.task, args))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def _chat(args) -> None:
|
|
150
|
+
cfg = load_config(args.config, provider=args.provider, model=args.model)
|
|
151
|
+
if args.yes:
|
|
152
|
+
cfg.agent.auto_approve_writes = True
|
|
153
|
+
cfg.agent.auto_approve_commands = True
|
|
154
|
+
workspace = str(Path(args.workspace).resolve())
|
|
155
|
+
io = TerminalIO(auto_approve=args.yes)
|
|
156
|
+
session = AgentSession(workspace, cfg, io) # one session => memory across turns
|
|
157
|
+
console.print(
|
|
158
|
+
Panel(
|
|
159
|
+
f"Euron Agent · [bold]{cfg.provider.name}[/bold] / {cfg.provider.model}\n"
|
|
160
|
+
f"workspace: {workspace}\n"
|
|
161
|
+
"Type a task. Commands: /exit, /reset, /yes (toggle auto-approve).",
|
|
162
|
+
border_style="cyan",
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
while True:
|
|
166
|
+
try:
|
|
167
|
+
msg = await asyncio.to_thread(Prompt.ask, "[bold cyan]you[/bold cyan]")
|
|
168
|
+
except (EOFError, KeyboardInterrupt):
|
|
169
|
+
break
|
|
170
|
+
msg = msg.strip()
|
|
171
|
+
if not msg:
|
|
172
|
+
continue
|
|
173
|
+
if msg in ("/exit", "/quit"):
|
|
174
|
+
break
|
|
175
|
+
if msg == "/reset":
|
|
176
|
+
session.messages.clear()
|
|
177
|
+
console.print("[dim]context cleared[/dim]")
|
|
178
|
+
continue
|
|
179
|
+
if msg == "/yes":
|
|
180
|
+
io.auto_approve = not io.auto_approve
|
|
181
|
+
console.print(f"[dim]auto-approve = {io.auto_approve}[/dim]")
|
|
182
|
+
continue
|
|
183
|
+
await session.run(msg)
|
|
184
|
+
console.print("[dim]bye[/dim]")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def cmd_chat(args) -> None:
|
|
188
|
+
asyncio.run(_chat(args))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def cmd_serve(args) -> None:
|
|
192
|
+
from .server import serve
|
|
193
|
+
|
|
194
|
+
console.print(f"[cyan]Euron Agent server[/cyan] on http://{args.host}:{args.port}")
|
|
195
|
+
serve(host=args.host, port=args.port, reload=args.reload)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def cmd_providers(args) -> None:
|
|
199
|
+
from rich.table import Table
|
|
200
|
+
|
|
201
|
+
cfg = load_config(args.config)
|
|
202
|
+
table = Table(title="Configured providers")
|
|
203
|
+
table.add_column("name")
|
|
204
|
+
table.add_column("active")
|
|
205
|
+
table.add_column("type")
|
|
206
|
+
table.add_column("model")
|
|
207
|
+
table.add_column("base_url")
|
|
208
|
+
for name, p in cfg.all_providers.items():
|
|
209
|
+
table.add_row(
|
|
210
|
+
name,
|
|
211
|
+
"●" if name == cfg.provider.name else "",
|
|
212
|
+
p.type,
|
|
213
|
+
p.model,
|
|
214
|
+
p.base_url or "(default)",
|
|
215
|
+
)
|
|
216
|
+
console.print(table)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def cmd_init(args) -> None:
|
|
220
|
+
backend = Path(__file__).resolve().parent.parent
|
|
221
|
+
pairs = [("config.example.yaml", "config.yaml"), (".env.example", ".env")]
|
|
222
|
+
for src, dst in pairs:
|
|
223
|
+
dst_path = Path.cwd() / dst
|
|
224
|
+
src_path = backend / src
|
|
225
|
+
if dst_path.exists():
|
|
226
|
+
console.print(f"[yellow]skip[/yellow] {dst} already exists")
|
|
227
|
+
elif src_path.exists():
|
|
228
|
+
shutil.copy(src_path, dst_path)
|
|
229
|
+
console.print(f"[green]created[/green] {dst}")
|
|
230
|
+
else:
|
|
231
|
+
console.print(f"[red]missing template[/red] {src}")
|
|
232
|
+
console.print("Edit config.yaml (pick a provider) and .env (add your key).")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# --------------------------------------------------------------------------- #
|
|
236
|
+
# Parser
|
|
237
|
+
# --------------------------------------------------------------------------- #
|
|
238
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
239
|
+
p = argparse.ArgumentParser(prog="euron-agent", description="Euron coding agent.")
|
|
240
|
+
p.add_argument("--config", help="Path to config.yaml")
|
|
241
|
+
p.add_argument("--provider", help="Override active provider profile")
|
|
242
|
+
p.add_argument("--model", help="Override model id")
|
|
243
|
+
p.add_argument(
|
|
244
|
+
"--workspace", default=os.getcwd(), help="Workspace root (default: cwd)"
|
|
245
|
+
)
|
|
246
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
247
|
+
|
|
248
|
+
r = sub.add_parser("run", help="Run a single task and exit")
|
|
249
|
+
r.add_argument("task")
|
|
250
|
+
r.add_argument("--yes", "-y", action="store_true", help="Auto-approve all actions")
|
|
251
|
+
r.set_defaults(func=cmd_run)
|
|
252
|
+
|
|
253
|
+
c = sub.add_parser("chat", help="Interactive REPL")
|
|
254
|
+
c.add_argument("--yes", "-y", action="store_true", help="Auto-approve all actions")
|
|
255
|
+
c.set_defaults(func=cmd_chat)
|
|
256
|
+
|
|
257
|
+
s = sub.add_parser("serve", help="Start the FastAPI server")
|
|
258
|
+
s.add_argument("--host", default="127.0.0.1")
|
|
259
|
+
s.add_argument("--port", type=int, default=8000)
|
|
260
|
+
s.add_argument("--reload", action="store_true")
|
|
261
|
+
s.set_defaults(func=cmd_serve)
|
|
262
|
+
|
|
263
|
+
sub.add_parser("providers", help="List configured providers").set_defaults(
|
|
264
|
+
func=cmd_providers
|
|
265
|
+
)
|
|
266
|
+
sub.add_parser("init", help="Scaffold config.yaml and .env").set_defaults(
|
|
267
|
+
func=cmd_init
|
|
268
|
+
)
|
|
269
|
+
return p
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def main(argv=None) -> int:
|
|
273
|
+
args = build_parser().parse_args(argv)
|
|
274
|
+
try:
|
|
275
|
+
args.func(args)
|
|
276
|
+
except KeyboardInterrupt:
|
|
277
|
+
console.print("\n[dim]interrupted[/dim]")
|
|
278
|
+
return 130
|
|
279
|
+
except Exception as e: # noqa: BLE001
|
|
280
|
+
console.print(f"[red]fatal:[/red] {type(e).__name__}: {e}")
|
|
281
|
+
return 1
|
|
282
|
+
return 0
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
if __name__ == "__main__":
|
|
286
|
+
sys.exit(main())
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Configuration loading and resolution.
|
|
2
|
+
|
|
3
|
+
Config is layered, in order of precedence (later wins):
|
|
4
|
+
1. built-in defaults
|
|
5
|
+
2. config.yaml (next to the package, the cwd, or $EURON_AGENT_CONFIG)
|
|
6
|
+
3. environment variables (.env is loaded automatically)
|
|
7
|
+
4. explicit overrides passed on the CLI / API call
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass, field, replace
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
from dotenv import load_dotenv
|
|
18
|
+
|
|
19
|
+
load_dotenv() # pull .env into os.environ if present
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# --------------------------------------------------------------------------- #
|
|
23
|
+
# Dataclasses
|
|
24
|
+
# --------------------------------------------------------------------------- #
|
|
25
|
+
@dataclass
|
|
26
|
+
class ProviderConfig:
|
|
27
|
+
name: str
|
|
28
|
+
type: str = "openai" # "openai" (OpenAI-compatible) or "anthropic"
|
|
29
|
+
base_url: Optional[str] = None
|
|
30
|
+
api_key_env: Optional[str] = None
|
|
31
|
+
api_key: Optional[str] = None # resolved from api_key_env at load time
|
|
32
|
+
model: str = "gpt-4o-mini"
|
|
33
|
+
temperature: float = 0.2
|
|
34
|
+
max_tokens: int = 4096
|
|
35
|
+
extra_headers: dict = field(default_factory=dict)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class AgentConfig:
|
|
40
|
+
max_steps: int = 30
|
|
41
|
+
stream: bool = True
|
|
42
|
+
auto_approve_reads: bool = True
|
|
43
|
+
auto_approve_writes: bool = False
|
|
44
|
+
auto_approve_commands: bool = False
|
|
45
|
+
max_file_bytes: int = 120_000
|
|
46
|
+
max_command_seconds: int = 60
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Config:
|
|
51
|
+
provider: ProviderConfig
|
|
52
|
+
agent: AgentConfig
|
|
53
|
+
ignore: list[str] = field(default_factory=list)
|
|
54
|
+
all_providers: dict[str, ProviderConfig] = field(default_factory=dict)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
DEFAULT_IGNORE = [
|
|
58
|
+
".git/**", "node_modules/**", "__pycache__/**", ".venv/**", "venv/**",
|
|
59
|
+
"dist/**", "build/**", "*.lock", ".env", ".env.*",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
# Built-in provider profiles so the extension (and a fresh install with no
|
|
63
|
+
# config.yaml) works out of the box: the user just picks a provider and supplies
|
|
64
|
+
# a key. Any of these can be overridden by a user-defined provider of the same
|
|
65
|
+
# name in config.yaml.
|
|
66
|
+
BUILTIN_PROVIDERS: dict[str, dict] = {
|
|
67
|
+
"euri": {
|
|
68
|
+
"type": "openai",
|
|
69
|
+
"base_url": "https://api.euron.one/api/v1",
|
|
70
|
+
"api_key_env": "EURI_API_KEY",
|
|
71
|
+
"model": "gpt-4.1-mini",
|
|
72
|
+
},
|
|
73
|
+
"openai": {
|
|
74
|
+
"type": "openai",
|
|
75
|
+
"base_url": "https://api.openai.com/v1",
|
|
76
|
+
"api_key_env": "OPENAI_API_KEY",
|
|
77
|
+
"model": "gpt-4o-mini",
|
|
78
|
+
},
|
|
79
|
+
"openrouter": {
|
|
80
|
+
"type": "openai",
|
|
81
|
+
"base_url": "https://openrouter.ai/api/v1",
|
|
82
|
+
"api_key_env": "OPENROUTER_API_KEY",
|
|
83
|
+
"model": "openai/gpt-4o-mini",
|
|
84
|
+
},
|
|
85
|
+
"ollama": {
|
|
86
|
+
"type": "openai",
|
|
87
|
+
"base_url": "http://localhost:11434/v1",
|
|
88
|
+
"api_key_env": None,
|
|
89
|
+
"model": "qwen2.5-coder:7b",
|
|
90
|
+
},
|
|
91
|
+
"anthropic": {
|
|
92
|
+
"type": "anthropic",
|
|
93
|
+
"base_url": None,
|
|
94
|
+
"api_key_env": "ANTHROPIC_API_KEY",
|
|
95
|
+
"model": "claude-sonnet-4-6",
|
|
96
|
+
},
|
|
97
|
+
# Generic OpenAI-compatible endpoint; base_url/model supplied at runtime.
|
|
98
|
+
"custom": {
|
|
99
|
+
"type": "openai",
|
|
100
|
+
"base_url": "http://localhost:8001/v1",
|
|
101
|
+
"api_key_env": None,
|
|
102
|
+
"model": "local-model",
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# --------------------------------------------------------------------------- #
|
|
108
|
+
# Loading
|
|
109
|
+
# --------------------------------------------------------------------------- #
|
|
110
|
+
def _candidate_paths(explicit: Optional[str]) -> list[Path]:
|
|
111
|
+
paths = []
|
|
112
|
+
if explicit:
|
|
113
|
+
paths.append(Path(explicit))
|
|
114
|
+
env = os.getenv("EURON_AGENT_CONFIG")
|
|
115
|
+
if env:
|
|
116
|
+
paths.append(Path(env))
|
|
117
|
+
paths.append(Path.cwd() / "config.yaml")
|
|
118
|
+
paths.append(Path(__file__).resolve().parent.parent / "config.yaml")
|
|
119
|
+
return paths
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _find_config_file(explicit: Optional[str]) -> Optional[Path]:
|
|
123
|
+
for p in _candidate_paths(explicit):
|
|
124
|
+
if p and p.is_file():
|
|
125
|
+
return p
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _provider_from_dict(name: str, d: dict) -> ProviderConfig:
|
|
130
|
+
api_key_env = d.get("api_key_env")
|
|
131
|
+
api_key = os.getenv(api_key_env) if api_key_env else None
|
|
132
|
+
return ProviderConfig(
|
|
133
|
+
name=name,
|
|
134
|
+
type=d.get("type", "openai"),
|
|
135
|
+
base_url=d.get("base_url"),
|
|
136
|
+
api_key_env=api_key_env,
|
|
137
|
+
api_key=api_key,
|
|
138
|
+
model=d.get("model", "gpt-4o-mini"),
|
|
139
|
+
temperature=float(d.get("temperature", 0.2)),
|
|
140
|
+
max_tokens=int(d.get("max_tokens", 4096)),
|
|
141
|
+
extra_headers=d.get("extra_headers", {}) or {},
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def load_config(
|
|
146
|
+
config_path: Optional[str] = None,
|
|
147
|
+
*,
|
|
148
|
+
provider: Optional[str] = None,
|
|
149
|
+
model: Optional[str] = None,
|
|
150
|
+
api_key: Optional[str] = None,
|
|
151
|
+
base_url: Optional[str] = None,
|
|
152
|
+
) -> Config:
|
|
153
|
+
"""Load configuration, applying optional overrides.
|
|
154
|
+
|
|
155
|
+
`api_key` / `base_url` let a caller (e.g. the VS Code extension) inject a
|
|
156
|
+
secret and endpoint at runtime so nothing has to live in a file.
|
|
157
|
+
"""
|
|
158
|
+
raw: dict[str, Any] = {}
|
|
159
|
+
cfg_file = _find_config_file(config_path)
|
|
160
|
+
if cfg_file:
|
|
161
|
+
raw = yaml.safe_load(cfg_file.read_text(encoding="utf-8")) or {}
|
|
162
|
+
|
|
163
|
+
# Start from the built-in profiles, then let config.yaml override/extend them.
|
|
164
|
+
merged: dict[str, dict] = {k: dict(v) for k, v in BUILTIN_PROVIDERS.items()}
|
|
165
|
+
for name, d in (raw.get("providers", {}) or {}).items():
|
|
166
|
+
merged[name] = {**merged.get(name, {}), **d}
|
|
167
|
+
all_providers = {name: _provider_from_dict(name, d) for name, d in merged.items()}
|
|
168
|
+
|
|
169
|
+
active = provider or raw.get("active") or "openai"
|
|
170
|
+
if active not in all_providers:
|
|
171
|
+
raise ValueError(
|
|
172
|
+
f"Provider '{active}' not found. Available: {', '.join(all_providers)}"
|
|
173
|
+
)
|
|
174
|
+
selected = all_providers[active]
|
|
175
|
+
overrides: dict[str, Any] = {}
|
|
176
|
+
if model:
|
|
177
|
+
overrides["model"] = model
|
|
178
|
+
if api_key: # non-empty only
|
|
179
|
+
overrides["api_key"] = api_key
|
|
180
|
+
if base_url:
|
|
181
|
+
overrides["base_url"] = base_url
|
|
182
|
+
if overrides:
|
|
183
|
+
selected = replace(selected, **overrides)
|
|
184
|
+
|
|
185
|
+
agent_raw = raw.get("agent", {}) or {}
|
|
186
|
+
agent = AgentConfig(
|
|
187
|
+
max_steps=int(agent_raw.get("max_steps", 30)),
|
|
188
|
+
stream=bool(agent_raw.get("stream", True)),
|
|
189
|
+
auto_approve_reads=bool(agent_raw.get("auto_approve_reads", True)),
|
|
190
|
+
auto_approve_writes=bool(agent_raw.get("auto_approve_writes", False)),
|
|
191
|
+
auto_approve_commands=bool(agent_raw.get("auto_approve_commands", False)),
|
|
192
|
+
max_file_bytes=int(agent_raw.get("max_file_bytes", 120_000)),
|
|
193
|
+
max_command_seconds=int(agent_raw.get("max_command_seconds", 60)),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
ignore = raw.get("ignore") or DEFAULT_IGNORE
|
|
197
|
+
|
|
198
|
+
return Config(
|
|
199
|
+
provider=selected,
|
|
200
|
+
agent=agent,
|
|
201
|
+
ignore=list(ignore),
|
|
202
|
+
all_providers=all_providers,
|
|
203
|
+
)
|