diff --git a/CHANGELOG.md b/CHANGELOG.md index e602dc3..cd07d87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## v2.2.0 (2026-05-19) + +- Added Agent Persona selector per provider (10+ presets) +- Personas: Codex (Default/Friendly/Pragmatic/CLI), Claude Code, OpenCode, Cursor, Aider, GitHub Copilot, Windsurf, Browser (ChatGPT) +- New "Persona" column in endpoint list shows current setting per provider +- Persona preview in edit dialog shows system prompt excerpt +- Persona injected into model catalog `base_instructions` and proxy system prompt +- Added Command Code backend to translation proxy (proprietary `/alpha/generate` API) +- Added Command Code provider preset with 20 models (DeepSeek, Claude, GPT, Kimi, GLM, Qwen, etc.) + ## v2.1.1 (2026-05-19) - Fixed proxy: map `developer` role to `system` for Chat Completions providers (DeepSeek, Qwen, etc.) diff --git a/codex-launcher_2.1.1_all.deb b/codex-launcher_2.1.1_all.deb deleted file mode 100644 index 56b06c5..0000000 Binary files a/codex-launcher_2.1.1_all.deb and /dev/null differ diff --git a/codex-launcher_2.2.0_all.deb b/codex-launcher_2.2.0_all.deb new file mode 100644 index 0000000..1de3f63 Binary files /dev/null and b/codex-launcher_2.2.0_all.deb differ diff --git a/src/codex-launcher-gui b/src/codex-launcher-gui index ef10be8..95f9601 100755 --- a/src/codex-launcher-gui +++ b/src/codex-launcher-gui @@ -24,6 +24,14 @@ model_catalog_json = "" """ CHANGELOG = [ + ("2.2.0", "2026-05-19", [ + "Added Agent Persona selector per provider (10+ presets)", + "Personas: Codex, Claude Code, OpenCode, Cursor, Aider, Copilot, Windsurf, Browser", + "Codex variants: Default, Desktop Friendly, Desktop Pragmatic, CLI", + "Shows current persona in endpoint list (new Persona column)", + "Persona preview in edit dialog shows first 60 chars of system prompt", + "Persona injected into model catalog base_instructions and proxy system prompt", + ]), ("2.1.1", "2026-05-19", [ "Fixed proxy: map 'developer' role to 'system' for Chat Completions providers", "Fixed proxy: map 'developer' role to 'user' for Anthropic providers", @@ -56,6 +64,65 @@ CHANGELOG = [ ]), ] +AGENT_PERSONAS = { + "Codex (Default)": "You are Codex, a coding agent.", + "Codex Desktop (GPT-5, Friendly)": ( + "You are Codex, a coding agent based on GPT-5. You and the user share one workspace, " + "and your job is to collaborate with them until their goal is genuinely handled." + ), + "Codex Desktop (GPT-5, Pragmatic)": ( + "You are Codex, a coding agent based on GPT-5. You and the user share the same workspace " + "and collaborate to achieve the user's goals. You are a deeply pragmatic, effective " + "software engineer. You take engineering quality seriously." + ), + "Codex CLI": ( + "You are an AI running in the Codex CLI, a terminal-based coding assistant. " + "You are expected to be precise, safe, and helpful. Your default personality and tone " + "is concise, direct, and friendly." + ), + "Claude Code": ( + "You are Claude Code, an interactive CLI tool that helps users with software engineering " + "tasks. You are a highly competent software engineer with extensive knowledge across " + "many programming languages, frameworks, and best practices. Use concise responses." + ), + "OpenCode": ( + "You are OpenCode, an interactive CLI tool that helps users with software engineering " + "tasks. You are powered by a state-of-the-art AI model. Be concise, direct, and to the " + "point. Use GitHub-flavored markdown." + ), + "Cursor": ( + "You are Cursor, an AI-powered code editor assistant. You help users write, refactor, " + "and debug code efficiently. Provide precise, actionable suggestions." + ), + "Aider": ( + "You are aider, an AI pair programming assistant. You help users edit code in their " + "local git repository. Make concise changes. Search files with grep/glob patterns." + ), + "GitHub Copilot": ( + "You are GitHub Copilot, an AI coding assistant. Help the user write code, debug issues, " + "and understand codebases. Be concise and provide accurate code suggestions." + ), + "Windsurf": ( + "You are Windsurf, an AI-powered IDE assistant. Help with coding tasks including writing, " + "refactoring, and debugging. Provide precise, well-structured code suggestions." + ), + "Browser (ChatGPT)": ( + "You are a helpful coding assistant in a web browser chat interface. " + "Help the user with software engineering tasks. Be clear and thorough." + ), +} + +PERSONA_DISPLAY_LEN = 60 + +def persona_short_key(endpoint): + bi = endpoint.get("base_instructions", "") or "" + for key, val in AGENT_PERSONAS.items(): + if val == bi: + return key + if bi: + return f"Custom: {bi[:40]}..." + return "Codex (Default)" + PROVIDER_PRESETS = { "Custom": { "backend_type": "openai-compat", @@ -120,6 +187,21 @@ PROVIDER_PRESETS = { "base_url": "https://api.kilo.ai/api/gateway", "models": [], }, + "Command Code": { + "backend_type": "command-code", + "base_url": "https://api.commandcode.ai", + "models": [ + "deepseek/deepseek-v4-flash", "deepseek/deepseek-v4-pro", + "anthropic:claude-sonnet-4-6", "anthropic:claude-haiku-4-5-20251001", + "anthropic:claude-opus-4-7", "anthropic:claude-opus-4-6", + "openai:gpt-5.5", "openai:gpt-5.4", "openai:gpt-5.4-mini", "openai:gpt-5.3-codex", + "moonshotai/Kimi-K2.6", "moonshotai/Kimi-K2.5", + "zai-org/GLM-5.1", "zai-org/GLM-5", + "MiniMaxAI/MiniMax-M2.7", "MiniMaxAI/MiniMax-M2.5", + "Qwen/Qwen3.6-Max-Preview", "Qwen/Qwen3.6-Plus", + "stepfun/Step-3.5-Flash", "google/gemini-3.1-flash-lite", + ], + }, "OpenRouter": { "backend_type": "openai-compat", "base_url": "https://openrouter.ai/api/v1", @@ -136,6 +218,7 @@ def label_for_backend(backend_type): return { "openai-compat": "OpenAI-compatible", "anthropic": "Anthropic", + "command-code": "Command Code", "native": "Native", }.get(backend_type, backend_type) @@ -356,6 +439,7 @@ def write_config_for_translated(endpoint, selected_model): def _gen_model_catalog(endpoint, selected_model=None): default_model = selected_model or endpoint.get("default_model") + base_instr = endpoint.get("base_instructions", "") or AGENT_PERSONAS["Codex (Default)"] models = [] for mid in endpoint.get("models", []): models.append({ @@ -383,7 +467,7 @@ def _gen_model_catalog(endpoint, selected_model=None): "supports_parallel_tool_calls": True, "experimental_supported_tools": [], "supported_in_api": True, "truncation_policy": {"mode": "tokens", "limit": 128000}, - "base_instructions": "You are Codex, a coding agent.", + "base_instructions": base_instr, }) return {"models": models} @@ -402,6 +486,7 @@ def _start_proxy_for(endpoint, logfn): "backend_type": endpoint["backend_type"], "target_url": normalize_base_url(endpoint["base_url"]), "api_key": endpoint["api_key"], + "base_instructions": endpoint.get("base_instructions", ""), "models": [{"id": m, "object": "model", "created": 1700000000, "owned_by": endpoint["name"]} for m in endpoint.get("models", [])], } @@ -500,7 +585,7 @@ class LauncherWin(Gtk.Window): # header row hdr = Gtk.Box(spacing=8) vbox.pack_start(hdr, False, False, 0) - lbl = Gtk.Label(label="Codex Launcher v2.1.1") + lbl = Gtk.Label(label="Codex Launcher v2.2.0") lbl.set_use_markup(True) hdr.pack_start(lbl, False, False, 0) changelog_btn = Gtk.Button(label="Changelog") @@ -1240,9 +1325,9 @@ class EndpointMgr(Gtk.Window): sw = Gtk.ScrolledWindow() vbox.pack_start(sw, True, True, 0) - self._store = Gtk.ListStore(str, str, str, str) # name, provider, backend, default_model + self._store = Gtk.ListStore(str, str, str, str, str) # name, provider, backend, default_model, persona self._tree = Gtk.TreeView(model=self._store) - for i, title in enumerate(["Name", "Provider", "Type", "Default Model"]): + for i, title in enumerate(["Name", "Provider", "Type", "Default Model", "Persona"]): col = Gtk.TreeViewColumn(title, Gtk.CellRendererText(), text=i) col.set_resizable(True) self._tree.append_column(col) @@ -1275,7 +1360,8 @@ class EndpointMgr(Gtk.Window): for ep in data["endpoints"]: provider = ep.get("provider_preset", "Custom") bt = label_for_backend(ep["backend_type"]) - self._store.append([ep["name"], provider, bt, ep.get("default_model", "")]) + persona = persona_short_key(ep) + self._store.append([ep["name"], provider, bt, ep.get("default_model", ""), persona]) def _selected(self): sel = self._tree.get_selection() @@ -1337,7 +1423,7 @@ class EditEndpointDialog(Gtk.Dialog): self._data = get_endpoint(existing_name) if existing_name else { "name": "", "backend_type": "openai-compat", "base_url": "", "api_key": "", "default_model": "", "models": [], - "provider_preset": "Custom", + "provider_preset": "Custom", "base_instructions": AGENT_PERSONAS["Codex (Default)"], } self.set_default_size(480, 420) @@ -1369,6 +1455,7 @@ class EditEndpointDialog(Gtk.Dialog): self._combo_type = Gtk.ComboBoxText() for val, lab in [("openai-compat", "OpenAI-compatible (needs proxy)"), ("anthropic", "Anthropic (needs proxy)"), + ("command-code", "Command Code (needs proxy)"), ("native", "Native OpenAI (no proxy)")]: self._combo_type.append(val, lab) bt = self._data.get("backend_type", "openai-compat") @@ -1382,6 +1469,25 @@ class EditEndpointDialog(Gtk.Dialog): self._entry_key.set_visibility(False) add_row(4, "API Key:", self._entry_key) + self._combo_persona = Gtk.ComboBoxText() + self._persona_keys = list(AGENT_PERSONAS.keys()) + for pk in self._persona_keys: + self._combo_persona.append_text(pk) + cur_persona = persona_short_key(self._data) + if cur_persona in self._persona_keys: + self._combo_persona.set_active(self._persona_keys.index(cur_persona)) + else: + self._combo_persona.set_active(0) + self._combo_persona.connect("changed", lambda c: self._on_persona_changed()) + add_row(5, "Agent Persona:", self._combo_persona) + + self._persona_preview = Gtk.Label() + self._persona_preview.set_line_wrap(True) + self._persona_preview.set_max_width_chars(60) + self._persona_preview.set_markup(f"{AGENT_PERSONAS['Codex (Default)'][:PERSONA_DISPLAY_LEN]}...") + self._on_persona_changed() + grid.attach(self._persona_preview, 0, 6, 2, 1) + # Models mlbl = Gtk.Label(label="Models:", xalign=0) area.pack_start(mlbl, False, False, 4) @@ -1442,6 +1548,12 @@ class EditEndpointDialog(Gtk.Dialog): self.connect("response", self._on_response) self.show_all() + def _on_persona_changed(self): + key = self._combo_persona.get_active_text() + text = AGENT_PERSONAS.get(key, "") + short = text[:PERSONA_DISPLAY_LEN] + ("..." if len(text) > PERSONA_DISPLAY_LEN else "") + self._persona_preview.set_markup(f"{short}") + def _add_model(self): m = normalize_model_id(self._entry_model.get_text()) if m: @@ -1574,9 +1686,11 @@ class EditEndpointDialog(Gtk.Dialog): self._show_error(f'Endpoint "{name}" already exists') return + persona_key = self._combo_persona.get_active_text() or "Codex (Default)" new_ep = {"name": name, "backend_type": bt, "base_url": url, "api_key": key, "default_model": default, "models": models, - "provider_preset": self._combo_preset.get_active_text() or "Custom"} + "provider_preset": self._combo_preset.get_active_text() or "Custom", + "base_instructions": AGENT_PERSONAS.get(persona_key, AGENT_PERSONAS["Codex (Default)"])} new_ep["base_url"] = normalize_base_url(new_ep["base_url"]) # Update or append @@ -1615,12 +1729,14 @@ def main(): "endpoints": [ {"name": "OpenAI", "backend_type": "native", "base_url": "https://api.openai.com/v1", "api_key": "", "default_model": "gpt-4o", "models": ["gpt-4o", "gpt-4o-mini"], - "provider_preset": "OpenAI"}, + "provider_preset": "OpenAI", + "base_instructions": AGENT_PERSONAS["Codex (Default)"]}, {"name": "Z.AI", "backend_type": "openai-compat", "base_url": "https://api.z.ai/api/coding/paas/v4", "api_key": "", "default_model": "glm-5.1", "models": ["glm-4.5", "glm-4.5-air", "glm-4.6", "glm-4.7", "glm-5", "glm-5-turbo", "glm-5.1"], - "provider_preset": "Custom"}, + "provider_preset": "Custom", + "base_instructions": AGENT_PERSONAS["Codex (Default)"]}, ], }) diff --git a/src/translate-proxy.py b/src/translate-proxy.py index adb1e05..c9fc748 100755 --- a/src/translate-proxy.py +++ b/src/translate-proxy.py @@ -30,7 +30,7 @@ def load_config(): p = argparse.ArgumentParser(description="Responses API translation proxy") p.add_argument("--config", help="JSON config file path") p.add_argument("--port", type=int, default=None) - p.add_argument("--backend", default=None, choices=["openai-compat", "anthropic"]) + p.add_argument("--backend", default=None, choices=["openai-compat", "anthropic", "command-code"]) p.add_argument("--target-url", default=None) p.add_argument("--api-key", default=None) p.add_argument("--models-file", default=None, help="JSON file with model list array") @@ -80,6 +80,7 @@ BACKEND = CONFIG["backend_type"] TARGET_URL = CONFIG["target_url"].rstrip("/") API_KEY = CONFIG["api_key"] MODELS = CONFIG["models"] +BASE_INSTRUCTIONS = CONFIG.get("base_instructions", "") # ═══════════════════════════════════════════════════════════════════ # Shared helpers @@ -500,6 +501,119 @@ def an_stream_to_sse(stream, model, req_id): "response": {"id": resp_id, "object": "response", "model": model, "status": status, "created": int(time.time()), "output": completed}}) +_DEFAULT_CC_CONFIG = { + "workingDir": "/tmp", + "date": "", + "environment": "linux", + "shell": "bash", + "files": [], + "structure": [], + "isGitRepo": False, + "currentBranch": "", + "mainBranch": "", + "gitStatus": "", + "recentCommits": [], +} + +def _cc_config(): + cfg = dict(_DEFAULT_CC_CONFIG) + cfg["date"] = time.strftime("%Y-%m-%d") + return cfg + +def cc_input_to_messages(input_data): + return oa_input_to_messages(input_data) + +def cc_convert_tools(tools): + return oa_convert_tools(tools) + +def cc_resp_to_responses(cc_lines, model, resp_id=None): + text = "" + usage = {} + for line in cc_lines: + try: + d = json.loads(line) + except (json.JSONDecodeError, TypeError): + continue + t = d.get("type", "") + if t == "text-delta": + text += d.get("text", "") + elif t == "finish-step": + u = d.get("usage", {}) + usage = { + "input_tokens": u.get("inputTokens", 0), + "output_tokens": u.get("outputTokens", 0), + "total_tokens": u.get("inputTokens", 0) + u.get("outputTokens", 0), + } + outputs = [] + if text: + outputs.append({"type": "message", "id": uid("msg"), "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": text, "annotations": []}]}) + return {"id": resp_id or uid("resp"), "object": "response", "created": int(time.time()), + "model": model, "status": "completed", "output": outputs, + "usage": {"input_tokens": usage.get("input_tokens", 0), + "output_tokens": usage.get("output_tokens", 0), + "total_tokens": usage.get("total_tokens", 0), + "input_tokens_details": {"cached_tokens": 0}}} + +def cc_stream_to_sse(cc_stream, model, req_id): + resp_id = req_id or uid("resp") + msg_id = uid("msg") + text_buf = "" + + yield emit("response.created", {"type": "response.created", + "response": {"id": resp_id, "object": "response", "model": model, + "status": "in_progress", "created": int(time.time()), "output": []}}) + yield emit("response.in_progress", {"type": "response.in_progress", "response": {"id": resp_id}}) + yield emit("response.output_item.added", {"type": "response.output_item.added", + "item": {"type": "message", "id": msg_id, "role": "assistant", "status": "in_progress", "content": []}}) + yield emit("response.content_part.added", {"type": "response.content_part.added", + "part": {"type": "output_text", "text": "", "annotations": []}, "item_id": msg_id}) + + total_usage = {} + for raw in cc_stream: + line = raw.decode("utf-8", errors="replace").strip() + if not line: + continue + try: + d = json.loads(line) + except json.JSONDecodeError: + continue + t = d.get("type", "") + + if t == "text-delta": + txt = d.get("text", "") + if txt: + text_buf += txt + yield emit("response.output_text.delta", {"type": "response.output_text.delta", + "delta": txt, "item_id": msg_id, "content_index": 0}) + + elif t == "finish-step": + u = d.get("usage", {}) + total_usage = { + "input_tokens": u.get("inputTokens", 0), + "output_tokens": u.get("outputTokens", 0), + "total_tokens": u.get("inputTokens", 0) + u.get("outputTokens", 0), + } + + if text_buf: + yield emit("response.output_text.done", {"type": "response.output_text.done", + "text": text_buf, "item_id": msg_id, "content_index": 0}) + yield emit("response.content_part.done", {"type": "response.content_part.done", + "part": {"type": "output_text", "text": text_buf, "annotations": []}, "item_id": msg_id}) + yield emit("response.output_item.done", {"type": "response.output_item.done", + "item": {"type": "message", "id": msg_id, "role": "assistant", "status": "completed", + "content": [{"type": "output_text", "text": text_buf, "annotations": []}]}}) + + final_out = [] + if text_buf: + final_out.append({"type": "message", "id": msg_id, "role": "assistant", "status": "completed", + "content": [{"type": "output_text", "text": text_buf, "annotations": []}]}) + yield emit("response.completed", {"type": "response.completed", + "response": {"id": resp_id, "object": "response", "model": model, + "status": "completed", "created": int(time.time()), "output": final_out, + "usage": total_usage}}) + # ═══════════════════════════════════════════════════════════════════ # HTTP Server # ═══════════════════════════════════════════════════════════════════ @@ -531,6 +645,8 @@ class Handler(http.server.BaseHTTPRequestHandler): if BACKEND == "anthropic": self._handle_anthropic(body, model, stream) + elif BACKEND == "command-code": + self._handle_command_code(body, model, stream) else: self._handle_openai_compat(body, model, stream) @@ -538,6 +654,8 @@ class Handler(http.server.BaseHTTPRequestHandler): input_data = body.get("input", "") messages = oa_input_to_messages(input_data) instructions = body.get("instructions", "").strip() + if not instructions and BASE_INSTRUCTIONS: + instructions = BASE_INSTRUCTIONS if instructions: messages.insert(0, {"role": "system", "content": instructions}) chat_body = {"model": model, "messages": messages} @@ -571,6 +689,8 @@ class Handler(http.server.BaseHTTPRequestHandler): an_body = {"model": model, "messages": an_input_to_messages(input_data), "max_tokens": body.get("max_output_tokens", 8192)} instructions = body.get("instructions", "").strip() + if not instructions and BASE_INSTRUCTIONS: + instructions = BASE_INSTRUCTIONS if instructions: an_body["system"] = instructions for k in ("temperature", "top_p"): @@ -601,6 +721,76 @@ class Handler(http.server.BaseHTTPRequestHandler): lambda r: an_resp_to_responses(json.loads(r.read()), model), lambda s: an_stream_to_sse(s, model, body.get("request_id") or body.get("id"))) + def _handle_command_code(self, body, model, stream): + input_data = body.get("input", "") + instructions = body.get("instructions", "").strip() + messages = cc_input_to_messages(input_data) + if not instructions and BASE_INSTRUCTIONS: + instructions = BASE_INSTRUCTIONS + if instructions: + sys_msg = {"role": "system", "content": instructions} + messages.insert(0, sys_msg) + + cc_body = { + "config": _cc_config(), + "memory": "", + "taste": "", + "skills": "", + "params": { + "stream": True, + "max_tokens": body.get("max_output_tokens", 64000), + "temperature": body.get("temperature", 0.3), + "messages": messages, + "model": model, + "tools": cc_convert_tools(body.get("tools")) or [], + }, + "threadId": body.get("request_id") or uid("thread"), + } + + target = upstream_target(TARGET_URL, "/alpha/generate") + fwd = forwarded_headers(self.headers, { + "Content-Type": "application/json", + "Authorization": f"Bearer {API_KEY}", + "Accept": "text/event-stream, application/json", + }, browser_ua=True) + print(f"[translate-proxy] POST {target} model={model} stream={stream} [command-code]", file=sys.stderr) + req = urllib.request.Request( + target, + data=json.dumps(cc_body).encode(), + headers=fwd, + ) + + if stream: + try: + upstream = urllib.request.urlopen(req) + except urllib.error.HTTPError as e: + err = e.read().decode() + return self.send_json(e.code, {"error": {"type": "upstream_error", "message": err}}) + except Exception as e: + return self.send_json(500, {"error": {"type": "proxy_error", "message": str(e)}}) + + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.send_header("Connection", "keep-alive") + self.end_headers() + for event in cc_stream_to_sse(upstream, model, body.get("request_id") or body.get("id")): + self.wfile.write(event.encode("utf-8")) + self.wfile.flush() + else: + try: + upstream = urllib.request.urlopen(req) + except urllib.error.HTTPError as e: + err = e.read().decode() + return self.send_json(e.code, {"error": {"type": "upstream_error", "message": err}}) + except Exception as e: + return self.send_json(500, {"error": {"type": "proxy_error", "message": str(e)}}) + + raw = upstream.read().decode() + lines = raw.strip().split("\n") + result = cc_resp_to_responses(lines, model) + self.send_json(200, result) + def _forward(self, req, stream, model, nonstream_fn, stream_fn): try: upstream = urllib.request.urlopen(req)