Skip to content

Commit 6c8c0de

Browse files
Schnickenpickclaude
andcommitted
Add configurable backend (/server), live model lists, reasoning picker; fix UltraCode dep ordering and quiz overuse
- /server: change base URL/API key/models-endpoint path via GUI picker or typed command, plus --base-url/--api-key flags and matching env vars. - /model and /models fetch live from the configured backend when set, falling back to the static catalog otherwise. - /reasoning gets an arrow-key picker for the no-arg case. - UltraCode leader now defaults to sequential depends_on instead of leaving groups independent by default, which caused build steps to fire in effectively random order. - System prompt now discourages reflexive clarification quizzes in favor of inferring from context. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b95b0ad commit 6c8c0de

7 files changed

Lines changed: 306 additions & 29 deletions

File tree

deepcodev3/CHANGELOG.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Changelog
2+
3+
## Unreleased
4+
5+
### Added
6+
- `/server` command — view/change the backend base URL, API key, and models
7+
endpoint path. Arrow-key GUI (`/server`) or typed form
8+
(`/server <url> [key]`, `/server models=<path>`, `/server reset`).
9+
- `--base-url` / `--api-key` CLI flags for a session-only backend override.
10+
- `DEEPCODE_BASE_URL` / `DEEPCODE_API_KEY` / `DEEPCODE_MODELS_PATH` env vars,
11+
read before saved config.
12+
- `/model` and `/models` now fetch the live model list from a configured
13+
custom backend (normalizes both `{"models":[...]}` and OpenAI/Anthropic
14+
`{"data":[...]}` shapes) instead of always using the built-in static
15+
catalog. Falls back to the static catalog if the backend has none/fails.
16+
- `/reasoning` with no argument now opens an arrow-key picker instead of
17+
erroring; typed form (`/reasoning high`) unchanged.
18+
19+
### Fixed
20+
- `/server`'s API-key prompt no longer says "leave blank for none" — the
21+
input box can't submit empty text. Now accepts `none`/`-`/`skip`/`n/a`.
22+
- Arrow-key pickers built with a "Cancel" item as the literal last option
23+
silently broke: `_pick_option`'s last slot is a dedicated free-text slot
24+
and returns the literal string `"__free__"`, not the option's label.
25+
`/server` and `/reasoning` pickers now map that back to "Cancel" instead
26+
of placing Cancel last.
27+
- UltraCode leader prompt biased toward leaving `depends_on` empty even for
28+
naturally sequential build steps, so independent-looking groups fired
29+
simultaneously and finished in effectively random order. Prompt now
30+
defaults to sequential dependencies unless groups are genuinely
31+
independent. Also fixed a `depend_on`/`depends_on` key mismatch between
32+
the prompt text and the parser.
33+
- System prompt's quiz/clarification guidance was vague enough that the
34+
model defaulted to asking a clarifying quiz before most tasks, even when
35+
the answer was inferable or obvious. Added explicit guidance: infer from
36+
context/conventions, only quiz for genuinely ambiguous or irreversible
37+
choices.

deepcodev3/src/deepcodev3/api.py

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,81 @@
1+
from __future__ import annotations
12
import asyncio
23
import json
4+
import os
35
import httpx
46
from typing import AsyncIterator
57

6-
BASE = "https://use-ai-production.up.railway.app"
8+
from . import storage
9+
10+
_DEFAULT_BASE = "https://use-ai-production.up.railway.app"
11+
_DEFAULT_MODELS_PATH = "/models"
12+
13+
14+
def get_base_url() -> str:
15+
"""Resolve the backend base URL: env var > saved config > built-in default.
16+
Lets anyone point DeepCode at their own server/provider (self-hosted proxy,
17+
Groq, OpenRouter, etc.) without touching code."""
18+
env = os.environ.get("DEEPCODE_BASE_URL")
19+
if env:
20+
return env.rstrip("/")
21+
cfg = storage.load_config()
22+
return (cfg.get("base_url") or _DEFAULT_BASE).rstrip("/")
23+
24+
25+
def get_api_key() -> str | None:
26+
env = os.environ.get("DEEPCODE_API_KEY")
27+
if env:
28+
return env
29+
cfg = storage.load_config()
30+
return cfg.get("api_key") or None
31+
32+
33+
def _headers() -> dict:
34+
h = {"Content-Type": "application/json"}
35+
key = get_api_key()
36+
if key:
37+
h["Authorization"] = f"Bearer {key}"
38+
return h
39+
40+
41+
def get_models_path() -> str:
42+
"""Which path lists available models on the configured backend — varies
43+
by provider convention (use.ai's own /models vs. the OpenAI/Anthropic-style
44+
/v1/models most other proxies use)."""
45+
env = os.environ.get("DEEPCODE_MODELS_PATH")
46+
if env:
47+
return "/" + env.strip("/")
48+
cfg = storage.load_config()
49+
path = cfg.get("models_path") or _DEFAULT_MODELS_PATH
50+
return "/" + path.strip("/")
51+
52+
53+
async def fetch_models() -> list[dict] | None:
54+
"""GET the configured models endpoint. Normalizes both shapes seen in the
55+
wild: {"models": [{"slug"/"id", "label"/"name"}]} and the OpenAI/Anthropic
56+
{"data": [{"id", ...}]}. Returns None on any failure — callers fall back
57+
to the static catalog rather than erroring the whole /models command."""
58+
base = get_base_url()
59+
path = get_models_path()
60+
try:
61+
async with httpx.AsyncClient(timeout=10) as client:
62+
resp = await client.get(f"{base}{path}", headers=_headers())
63+
resp.raise_for_status()
64+
data = resp.json()
65+
except Exception:
66+
return None
67+
68+
if isinstance(data.get("models"), list):
69+
return [
70+
{"id": m.get("slug") or m.get("id", ""), "name": m.get("label") or m.get("name") or m.get("slug", "")}
71+
for m in data["models"] if m.get("slug") or m.get("id")
72+
]
73+
if isinstance(data.get("data"), list):
74+
return [
75+
{"id": m.get("id", ""), "name": m.get("display_name") or m.get("name") or m.get("id", "")}
76+
for m in data["data"] if m.get("id")
77+
]
78+
return None
779

880
# Transient network failures (DNS blip "getaddrinfo failed", connect drop, read
981
# timeout) that are safe to retry. Retry is only safe BEFORE the first delta is
@@ -18,8 +90,6 @@
1890
_MAX_RETRIES = 4 # total attempts = 1 + retries
1991
_BACKOFF_BASE = 1.5 # seconds: 1.5, 3, 6, 12
2092

21-
HEADERS = {"Content-Type": "application/json"}
22-
2393

2494
async def _stream_endpoint(path: str, body: dict, timeout: float) -> AsyncIterator[dict]:
2595
"""POST to an OpenAI-compatible streaming endpoint and yield normalized
@@ -33,11 +103,13 @@ async def _stream_endpoint(path: str, body: dict, timeout: float) -> AsyncIterat
33103
text, so it propagates to the caller.
34104
"""
35105
attempt = 0
106+
base = get_base_url()
107+
headers = _headers()
36108
while True:
37109
yielded = False
38110
try:
39111
async with httpx.AsyncClient(timeout=timeout) as client:
40-
async with client.stream("POST", f"{BASE}{path}", json=body, headers=HEADERS) as resp:
112+
async with client.stream("POST", f"{base}{path}", json=body, headers=headers) as resp:
41113
resp.raise_for_status()
42114
async for line in resp.aiter_lines():
43115
if not line.startswith("data: "):

deepcodev3/src/deepcodev3/chat.py

Lines changed: 128 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -139,16 +139,21 @@ def _render(sel: int):
139139
_render(selected)
140140

141141

142-
def _model_picker(current_model_id: str) -> str | None:
143-
"""Arrow-key model picker grouped by provider. Returns chosen model id, or None if cancelled."""
142+
def _model_picker(current_model_id: str, models: list[dict] | None = None) -> str | None:
143+
"""Arrow-key model picker. With the built-in static catalog (models=None),
144+
groups by provider and shows tier. With a live-fetched list (flat, no
145+
provider/tier metadata), renders a plain flat list instead."""
144146
from .models import MODELS, PROVIDERS, TIER_COLORS
147+
live = models is not None
148+
if not live:
149+
models = MODELS
145150

146151
# Build a flat list of rows: ("header", provider_name) or ("model", model_dict)
147152
rows: list[tuple[str, object]] = []
148153
last_provider = None
149154
selectable: list[int] = [] # indices into rows that are selectable
150-
for m in MODELS:
151-
if m["provider"] != last_provider:
155+
for m in models:
156+
if not live and m["provider"] != last_provider:
152157
last_provider = m["provider"]
153158
p = PROVIDERS.get(last_provider, {})
154159
rows.append(("header", p.get("name", last_provider)))
@@ -168,10 +173,13 @@ def _render():
168173
renderer.console.print(f" [bold]{val}[/bold]")
169174
continue
170175
m = val
171-
tier_color = TIER_COLORS.get(m["tier"], "white")
172176
is_cur = m["id"] == current_model_id
173177
mark = " ◀" if is_cur else ""
174-
label = f"{m['name']:<24} [{tier_color}]{m['tier']:<10}[/{tier_color}]{mark}"
178+
if live:
179+
label = f"{m['name']:<30} [cyan]{m['id']}[/cyan]{mark}"
180+
else:
181+
tier_color = TIER_COLORS.get(m["tier"], "white")
182+
label = f"{m['name']:<24} [{tier_color}]{m['tier']:<10}[/{tier_color}]{mark}"
175183
if ri == selectable[selected_idx]:
176184
renderer.console.print(f" [bold {renderer.PERMISSION_BLUE}]❯ {label}[/bold {renderer.PERMISSION_BLUE}]")
177185
else:
@@ -322,9 +330,10 @@ def _build_prompt(context_block: str) -> str:
322330
("/notify", "Toggle bell notification on/off"),
323331
("/color", "Set UI accent color — e.g. /color blue, /color #78aaff"),
324332
("/context", "Scan project for context — /context on|off to toggle auto-scan"),
325-
("/reasoning", "Set reasoning level — off/low/middle/high/ultra"),
333+
("/reasoning", "Set reasoning level — /reasoning for picker, or off/low/middle/high/ultra"),
326334
("/agent", "Toggle agent mode (file/shell tools)"),
327335
("/model", "Switch model — e.g. /model opus"),
336+
("/server", "Set backend URL/key/models path — /server, /server <url> [key], /server models=<path>, /server reset"),
328337
("/models", "List all 34 models"),
329338
("/merge", "Toggle Merge AI mode"),
330339
("/search", "Toggle Web Search mode"),
@@ -1277,8 +1286,17 @@ async def _run_bootstrap():
12771286
await _run_bootstrap()
12781287

12791288
elif cmd == "/model":
1289+
# Only bother fetching live when a non-default backend is configured —
1290+
# otherwise keep the nicer grouped static catalog for use.ai.
1291+
live_models = await api.fetch_models() if cfg.get("base_url") else None
12801292
if arg:
1281-
found = find_model(arg)
1293+
found = None
1294+
if live_models:
1295+
q = arg.lower().strip()
1296+
found = next((m for m in live_models
1297+
if q in m["id"].lower() or q in m["name"].lower()), None)
1298+
if not found:
1299+
found = find_model(arg)
12821300
if found:
12831301
model_id = found["id"]
12841302
mode = "chat"
@@ -1289,7 +1307,7 @@ async def _run_bootstrap():
12891307
else:
12901308
renderer.print_error(f"No model matching '{arg}'. Try /models.")
12911309
else:
1292-
chosen_id = _model_picker(model_id)
1310+
chosen_id = _model_picker(model_id, models=live_models)
12931311
if chosen_id:
12941312
model_id = chosen_id
12951313
mode = "chat"
@@ -1299,7 +1317,12 @@ async def _run_bootstrap():
12991317
renderer.print_model_status(model_id, mode, agent_mode)
13001318

13011319
elif cmd == "/models":
1302-
renderer.print_models_list(model_id)
1320+
live = await api.fetch_models() if cfg.get("base_url") else None
1321+
if live:
1322+
renderer.print_info(f"Live from {api.get_base_url()}{api.get_models_path()}:")
1323+
renderer.print_live_models_list(live, model_id)
1324+
else:
1325+
renderer.print_models_list(model_id)
13031326

13041327
elif cmd == "/merge":
13051328
mode = "chat" if mode == "merge" else "merge"
@@ -1360,17 +1383,37 @@ async def _run_bootstrap():
13601383

13611384
elif cmd == "/reasoning":
13621385
lvl = arg.lower().strip()
1363-
if lvl == "off" or (not lvl and reasoning_level):
1386+
if not lvl:
1387+
levels = ["off"] + list(REASONING_LEVELS.keys())
1388+
cur = reasoning_level or "off"
1389+
labeled = [f"{l} (current)" if l == cur else l for l in levels]
1390+
options = labeled + ["Cancel"]
1391+
renderer.console.print()
1392+
renderer.console.print(f" [bold]Reasoning level[/bold] [dim]current: {cur}[/dim]")
1393+
choice = _pick_option(options, controller)
1394+
# _pick_option's last list item is its dedicated "free text" slot and
1395+
# comes back as the literal string "__free__", not its label — map it
1396+
# back to "Cancel" (the actual last option here) rather than mis-parsing it.
1397+
if choice == "__free__":
1398+
choice = "Cancel"
1399+
chosen_lvl = None if choice in (None, "Cancel") else choice.split(" ", 1)[0]
1400+
if chosen_lvl and chosen_lvl != cur:
1401+
reasoning_level = None if chosen_lvl == "off" else chosen_lvl
1402+
cfg["reasoning"] = reasoning_level
1403+
storage.save_config(cfg)
1404+
renderer.print_info(f"Reasoning: {chosen_lvl}")
1405+
elif lvl == "off":
13641406
reasoning_level = None
13651407
cfg["reasoning"] = None
13661408
renderer.print_info("Reasoning OFF.")
1409+
storage.save_config(cfg)
13671410
elif lvl in REASONING_LEVELS:
13681411
reasoning_level = lvl
13691412
cfg["reasoning"] = lvl
13701413
renderer.print_info(f"Reasoning: {lvl}")
1414+
storage.save_config(cfg)
13711415
else:
13721416
renderer.print_error(f"Unknown level '{lvl}'. Use: off, low, middle, high, ultra")
1373-
storage.save_config(cfg)
13741417

13751418
elif cmd == "/color":
13761419
spec = arg.strip()
@@ -1393,6 +1436,79 @@ async def _run_bootstrap():
13931436
renderer.print_model_status(model_id, mode, agent_mode)
13941437
renderer.print_info(f"Color set to {spec}.")
13951438

1439+
elif cmd == "/server":
1440+
spec = arg.strip()
1441+
_NO_KEY_WORDS = {"none", "no", "-", "skip", "n/a"}
1442+
if not spec:
1443+
cur = cfg.get("base_url") or api._DEFAULT_BASE
1444+
has_key = bool(cfg.get("api_key"))
1445+
cur_models_path = cfg.get("models_path") or api._DEFAULT_MODELS_PATH
1446+
renderer.console.print()
1447+
renderer.console.print(f" [bold]Backend[/bold] [dim]{cur}{' · API key set' if has_key else ' · no API key'} · models: {cur_models_path}[/dim]")
1448+
options = ["Enter a custom URL", "Set models endpoint path",
1449+
"Reset to default", "Cancel", "Keep current"]
1450+
choice = _pick_option(options, controller)
1451+
if choice in (None, "Cancel", "Keep current", "__free__"):
1452+
pass
1453+
elif choice == "Reset to default":
1454+
cfg.pop("base_url", None)
1455+
cfg.pop("api_key", None)
1456+
cfg.pop("models_path", None)
1457+
storage.save_config(cfg)
1458+
renderer.print_info(f"Backend reset to default ({api._DEFAULT_BASE}).")
1459+
elif choice == "Set models endpoint path":
1460+
renderer.print_info(f"Models endpoint path (current: {cur_models_path}). "
1461+
"Common values: /models or /v1/models")
1462+
path_in = await controller.read_one() if controller else None
1463+
path_in = (path_in or "").strip()
1464+
if path_in:
1465+
cfg["models_path"] = "/" + path_in.strip("/")
1466+
storage.save_config(cfg)
1467+
renderer.print_info(f"Models endpoint set to {cfg['models_path']}.")
1468+
elif choice == "Enter a custom URL":
1469+
renderer.print_info("Base URL (http:// or https://):")
1470+
url_in = await controller.read_one() if controller else None
1471+
url_in = (url_in or "").strip()
1472+
if not (url_in.startswith("http://") or url_in.startswith("https://")):
1473+
renderer.print_error("Cancelled — base URL must start with http:// or https://")
1474+
else:
1475+
renderer.print_info("API key — type it, or type none if this backend doesn't need one:")
1476+
key_in = await controller.read_one() if controller else None
1477+
key_in = (key_in or "").strip()
1478+
cfg["base_url"] = url_in
1479+
if key_in and key_in.lower() not in _NO_KEY_WORDS:
1480+
cfg["api_key"] = key_in
1481+
else:
1482+
cfg.pop("api_key", None)
1483+
storage.save_config(cfg)
1484+
renderer.print_info(f"Backend set to {url_in}{' with API key' if key_in and key_in.lower() not in _NO_KEY_WORDS else ''}.")
1485+
elif spec.lower() == "reset":
1486+
cfg.pop("base_url", None)
1487+
cfg.pop("api_key", None)
1488+
cfg.pop("models_path", None)
1489+
storage.save_config(cfg)
1490+
renderer.print_info(f"Backend reset to default ({api._DEFAULT_BASE}).")
1491+
elif spec.lower().startswith("models="):
1492+
path_in = spec.split("=", 1)[1].strip()
1493+
if path_in:
1494+
cfg["models_path"] = "/" + path_in.strip("/")
1495+
storage.save_config(cfg)
1496+
renderer.print_info(f"Models endpoint set to {cfg['models_path']}.")
1497+
else:
1498+
renderer.print_error("Usage: /server models=<path> e.g. /server models=/v1/models")
1499+
else:
1500+
parts_srv = spec.split(None, 1)
1501+
url = parts_srv[0]
1502+
key = parts_srv[1].strip() if len(parts_srv) > 1 else None
1503+
if not (url.startswith("http://") or url.startswith("https://")):
1504+
renderer.print_error("Base URL must start with http:// or https://")
1505+
else:
1506+
cfg["base_url"] = url
1507+
if key and key.lower() not in _NO_KEY_WORDS:
1508+
cfg["api_key"] = key
1509+
storage.save_config(cfg)
1510+
renderer.print_info(f"Backend set to {url}{' with API key' if key and key.lower() not in _NO_KEY_WORDS else ''}.")
1511+
13961512
elif cmd == "/context":
13971513
sub = arg.strip().lower()
13981514
if sub == "off":

0 commit comments

Comments
 (0)