diff --git a/backend/src/agent/_confirmation.py b/backend/src/agent/_confirmation.py
index af69e0aac..83be12b36 100644
--- a/backend/src/agent/_confirmation.py
+++ b/backend/src/agent/_confirmation.py
@@ -12,6 +12,7 @@ from typing import Any
from langchain_openai import ChatOpenAI
+from src.agent._llm_params import chat_openai_kwargs
from src.agent._tool_resolver import (
extract_tool_call_from_state,
find_tool,
@@ -284,13 +285,12 @@ async def _format_tool_output_via_llm(
config = await _fetch_llm_config()
if config and config.get("configured"):
try:
- llm = ChatOpenAI(
+ llm = ChatOpenAI(**chat_openai_kwargs(
model=config.get("default_model", "gpt-4o-mini"),
base_url=config.get("base_url", "https://api.openai.com/v1"),
api_key=config["api_key"],
- temperature=0,
max_tokens=1024,
- )
+ ))
prompt = (
f"Tool '{tool_name}' returned this data:\n\n{text}\n\n"
"Summarize this data in a concise, human-readable format. "
diff --git a/backend/src/agent/_llm_params.py b/backend/src/agent/_llm_params.py
new file mode 100644
index 000000000..78f902719
--- /dev/null
+++ b/backend/src/agent/_llm_params.py
@@ -0,0 +1,70 @@
+# backend/src/agent/_llm_params.py
+# #region AgentChat.LlmParams [C:3] [TYPE Module] [SEMANTICS agent-chat,llm,openai,compatibility]
+# @defgroup AgentChat Shared LLM parameter compatibility helpers.
+# @LAYER Service
+# @BRIEF Build provider-safe ChatOpenAI kwargs and raw OpenAI payloads.
+# @POST Unsupported sampling parameters are omitted for reasoning/codex models.
+# @RATIONALE Some OpenAI-compatible gateways reject temperature for reasoning/codex
+# models. Centralising the guard prevents resume, health-check, and title
+# generation paths from diverging.
+
+from typing import Any
+
+
+_TEMPERATURE_UNSUPPORTED_PREFIXES = (
+ "codex/",
+ "omni/codex/",
+ "gpt-5",
+ "o1",
+ "o3",
+ "o4",
+)
+
+
+def _canonical_model_name(model: str | None) -> str:
+ """Return provider-stripped, lowercase model name for compatibility checks."""
+ name = (model or "").strip().lower()
+ if name.startswith(("codex/", "omni/codex/")):
+ return name
+ if "/" in name:
+ return name.rsplit("/", 1)[-1]
+ return name
+
+
+def supports_temperature(model: str | None) -> bool:
+ """Return False for model families whose APIs reject temperature."""
+ name = _canonical_model_name(model)
+ return not any(name.startswith(prefix) for prefix in _TEMPERATURE_UNSUPPORTED_PREFIXES)
+
+
+def chat_openai_kwargs(
+ *,
+ model: str,
+ base_url: str | None,
+ api_key: str,
+ max_tokens: int,
+ temperature: float = 0,
+) -> dict[str, Any]:
+ """Build ChatOpenAI kwargs without unsupported temperature for reasoning models."""
+ kwargs: dict[str, Any] = {
+ "model": model,
+ "base_url": base_url,
+ "api_key": api_key,
+ "max_tokens": max_tokens,
+ }
+ if supports_temperature(model):
+ kwargs["temperature"] = temperature
+ return kwargs
+
+
+def add_temperature_if_supported(
+ payload: dict[str, Any],
+ *,
+ model: str | None,
+ temperature: float = 0,
+) -> dict[str, Any]:
+ """Mutate and return payload with temperature only when the model supports it."""
+ if supports_temperature(model):
+ payload["temperature"] = temperature
+ return payload
+# #endregion AgentChat.LlmParams
diff --git a/backend/src/agent/_persistence.py b/backend/src/agent/_persistence.py
index c58ed0377..9cb82f312 100644
--- a/backend/src/agent/_persistence.py
+++ b/backend/src/agent/_persistence.py
@@ -16,6 +16,7 @@ import uuid
import httpx
from src.agent._config import AGENT_PREFETCH_DASHBOARD_LIMIT as _PREFETCH_LIMIT, FASTAPI_URL, SERVICE_JWT as _SERVICE_JWT
+from src.agent._llm_params import add_temperature_if_supported
from src.core.logger import logger
SAVE_API_URL = FASTAPI_URL + "/api/agent/conversations/save"
@@ -195,8 +196,8 @@ async def _call_llm_for_title(user_text: str) -> str | None:
"model": model,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 15,
- "temperature": 0,
}
+ add_temperature_if_supported(payload, model=model)
headers = {
"Content-Type": "application/json",
diff --git a/backend/src/agent/app.py b/backend/src/agent/app.py
index e672c1a83..b70a092b8 100644
--- a/backend/src/agent/app.py
+++ b/backend/src/agent/app.py
@@ -34,6 +34,7 @@ from langchain_openai import ChatOpenAI
from openai import APIConnectionError, APITimeoutError, AuthenticationError
from src.agent._config import GRADIO_SERVER_NAME, GRADIO_SERVER_PORT, STORAGE_ROOT as _STORAGE_ROOT
+from src.agent._llm_params import chat_openai_kwargs
from src.agent._confirmation import (
_pending_confirmations,
confirmation_payload,
@@ -216,13 +217,12 @@ async def _check_llm_provider_health() -> str:
if not config or not config.get("configured"):
return _llm_status["status"]
- llm = ChatOpenAI(
+ llm = ChatOpenAI(**chat_openai_kwargs(
model=config.get("default_model", "gpt-4o-mini"),
base_url=config.get("base_url", "https://api.openai.com/v1"),
api_key=config.get("api_key", ""),
- temperature=0,
max_tokens=1,
- )
+ ))
await llm.ainvoke([HumanMessage(content="ping")])
_llm_status["status"] = "ok"
_llm_status["last_error"] = ""
@@ -244,6 +244,11 @@ async def _check_llm_provider_health() -> str:
_llm_status["last_check_ts"] = time.time()
return "auth_error"
except Exception as exc:
+ if "usage_metadata.total_tokens" in str(exc):
+ _llm_status["status"] = "ok"
+ _llm_status["last_error"] = ""
+ _llm_status["last_check_ts"] = time.time()
+ return "ok"
logger.explore("LLM health check failed",
error=str(exc),
extra={"src": "AgentChat.GradioApp.LlmHealthCheck"})
@@ -535,11 +540,24 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
if kind == "on_chat_model_stream":
chunk = event["data"]["chunk"]
if hasattr(chunk, "content") and chunk.content:
+ content = chunk.content
+ if isinstance(content, str):
+ token_text = content
+ elif isinstance(content, list):
+ token_text = "".join(
+ str(item.get("text") or item.get("content") or "")
+ if isinstance(item, dict) else str(item)
+ for item in content
+ )
+ else:
+ token_text = str(content)
+ if not token_text:
+ continue
emitted_any = True
- assistant_parts.append(chunk.content)
+ assistant_parts.append(token_text)
yield json.dumps({
- "content": chunk.content,
- "metadata": {"type": "stream_token", "token": chunk.content},
+ "content": token_text,
+ "metadata": {"type": "stream_token", "token": token_text},
})
elif kind == "on_tool_start":
tool_name = event["name"]
@@ -677,14 +695,15 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
"content": f"❌ Ошибка: {exc}",
"metadata": {"type": "error", "code": "PROCESSING_ERROR", "detail": str(exc)},
})
- await save_conversation(conv_id, visible_user_text, user_id, assistant_text="".join(assistant_parts))
+ await save_conversation(conv_id, visible_user_text, user_id, assistant_text="".join(str(part) for part in assistant_parts))
return
- await save_conversation(conv_id, visible_user_text, user_id, assistant_text="".join(assistant_parts))
+ assistant_text = "".join(str(part) for part in assistant_parts)
+ await save_conversation(conv_id, visible_user_text, user_id, assistant_text=assistant_text)
await _generate_title_best_effort(conv_id, visible_user_text)
logger.reflect(
"Agent handler completed",
- payload={"conv_id": conv_id, "assistant_len": len("".join(assistant_parts))},
+ payload={"conv_id": conv_id, "assistant_len": len(assistant_text)},
extra={"src": "AgentChat.GradioApp.Handler"},
)
diff --git a/backend/src/agent/langgraph_setup.py b/backend/src/agent/langgraph_setup.py
index 16dd0d2d0..311c98c64 100644
--- a/backend/src/agent/langgraph_setup.py
+++ b/backend/src/agent/langgraph_setup.py
@@ -21,6 +21,7 @@ from langgraph.prebuilt import create_react_agent
from psycopg.rows import dict_row
from src.agent._config import FASTAPI_URL, AGENT_CONFIRM_TOOLS, AGENT_INTERRUPT_BEFORE as _INTERRUPT_BEFORE
+from src.agent._llm_params import chat_openai_kwargs
from src.core.logger import logger
# ── Monkey-patch: OpenAI SDK for Pydantic BaseModel classes ──
@@ -167,13 +168,12 @@ async def create_agent(
extra={"src": "AgentChat.LangGraph.Setup"},
)
- llm = ChatOpenAI(
+ llm = ChatOpenAI(**chat_openai_kwargs(
model=model,
base_url=base_url,
api_key=api_key,
- temperature=0,
max_tokens=2048,
- )
+ ))
# System prompt — env_id injected deterministically, not in user message
prompt = (
diff --git a/backend/src/api/routes/__tests__/test_git_status_route.py b/backend/src/api/routes/__tests__/test_git_status_route.py
index 707191fd7..d8c44c941 100644
--- a/backend/src/api/routes/__tests__/test_git_status_route.py
+++ b/backend/src/api/routes/__tests__/test_git_status_route.py
@@ -9,6 +9,12 @@ from unittest.mock import MagicMock
from fastapi import HTTPException
from src.api.routes import git as git_routes
+from src.api.routes.git import _helpers as git_helpers
+from src.services.git._sync import GitServiceSyncMixin
+
+
+async def _resolved_dashboard_id(*_args, **_kwargs):
+ return 12
# #region test_get_repository_status_returns_no_repo_payload_for_missing_repo [TYPE Function]
@@ -18,9 +24,9 @@ from src.api.routes import git as git_routes
# @POST Route returns a deterministic NO_REPO status payload.
def test_get_repository_status_returns_no_repo_payload_for_missing_repo(monkeypatch):
class MissingRepoGitService:
- def _get_repo_path(self, dashboard_id: int) -> str:
+ async def _get_repo_path(self, dashboard_id: int) -> str:
return f"/tmp/missing-repo-{dashboard_id}"
- def get_status(self, dashboard_id: int) -> dict:
+ async def get_status(self, dashboard_id: int) -> dict:
raise AssertionError("get_status must not be called when repository path is missing")
monkeypatch.setattr(git_routes, "git_service", MissingRepoGitService())
response = asyncio.run(git_routes.get_repository_status(34))
@@ -36,9 +42,9 @@ def test_get_repository_status_returns_no_repo_payload_for_missing_repo(monkeypa
# @POST Raised exception preserves original status and detail.
def test_get_repository_status_propagates_non_404_http_exception(monkeypatch):
class ConflictGitService:
- def _get_repo_path(self, dashboard_id: int) -> str:
+ async def _get_repo_path(self, dashboard_id: int) -> str:
return f"/tmp/existing-repo-{dashboard_id}"
- def get_status(self, dashboard_id: int) -> dict:
+ async def get_status(self, dashboard_id: int) -> dict:
raise HTTPException(status_code=409, detail="Conflict")
monkeypatch.setattr(git_routes, "git_service", ConflictGitService())
monkeypatch.setattr(git_routes.os.path, "exists", lambda _path: True)
@@ -54,7 +60,7 @@ def test_get_repository_status_propagates_non_404_http_exception(monkeypatch):
# @POST Endpoint raises same HTTPException values.
def test_get_repository_diff_propagates_http_exception(monkeypatch):
class DiffGitService:
- def get_diff(self, dashboard_id: int, file_path=None, staged: bool = False) -> str:
+ async def get_diff(self, dashboard_id: int, file_path=None, staged: bool = False) -> str:
raise HTTPException(status_code=404, detail="Repository missing")
monkeypatch.setattr(git_routes, "git_service", DiffGitService())
with pytest.raises(HTTPException) as exc_info:
@@ -69,7 +75,7 @@ def test_get_repository_diff_propagates_http_exception(monkeypatch):
# @POST Endpoint returns HTTPException with status 500 and route context.
def test_get_history_wraps_unexpected_error_as_500(monkeypatch):
class HistoryGitService:
- def get_commit_history(self, dashboard_id: int, limit: int = 50):
+ async def get_commit_history(self, dashboard_id: int, limit: int = 50):
raise ValueError("broken parser")
monkeypatch.setattr(git_routes, "git_service", HistoryGitService())
with pytest.raises(HTTPException) as exc_info:
@@ -84,7 +90,7 @@ def test_get_history_wraps_unexpected_error_as_500(monkeypatch):
# @POST Endpoint raises HTTPException(500) with route context.
def test_commit_changes_wraps_unexpected_error_as_500(monkeypatch):
class CommitGitService:
- def commit_changes(self, dashboard_id: int, message: str, files):
+ async def commit_changes(self, dashboard_id: int, message: str, files):
raise RuntimeError("index lock")
class CommitPayload:
message = "test"
@@ -102,9 +108,9 @@ def test_commit_changes_wraps_unexpected_error_as_500(monkeypatch):
# @POST Returned map includes resolved status for each requested dashboard ID.
def test_get_repository_status_batch_returns_mixed_statuses(monkeypatch):
class BatchGitService:
- def _get_repo_path(self, dashboard_id: int) -> str:
+ async def _get_repo_path(self, dashboard_id: int) -> str:
return f"/tmp/repo-{dashboard_id}"
- def get_status(self, dashboard_id: int) -> dict:
+ async def get_status(self, dashboard_id: int) -> dict:
if dashboard_id == 2:
return {"sync_state": "SYNCED", "sync_status": "OK"}
raise HTTPException(status_code=404, detail="not found")
@@ -123,9 +129,9 @@ def test_get_repository_status_batch_returns_mixed_statuses(monkeypatch):
# @POST Failed dashboard status is marked as ERROR.
def test_get_repository_status_batch_marks_item_as_error_on_service_failure(monkeypatch):
class BatchErrorGitService:
- def _get_repo_path(self, dashboard_id: int) -> str:
+ async def _get_repo_path(self, dashboard_id: int) -> str:
return f"/tmp/repo-{dashboard_id}"
- def get_status(self, dashboard_id: int) -> dict:
+ async def get_status(self, dashboard_id: int) -> dict:
raise RuntimeError("boom")
monkeypatch.setattr(git_routes, "git_service", BatchErrorGitService())
monkeypatch.setattr(git_routes.os.path, "exists", lambda _path: True)
@@ -142,9 +148,9 @@ def test_get_repository_status_batch_marks_item_as_error_on_service_failure(monk
# @POST Result contains unique IDs up to configured cap.
def test_get_repository_status_batch_deduplicates_and_truncates_ids(monkeypatch):
class SafeBatchGitService:
- def _get_repo_path(self, dashboard_id: int) -> str:
+ async def _get_repo_path(self, dashboard_id: int) -> str:
return f"/tmp/repo-{dashboard_id}"
- def get_status(self, dashboard_id: int) -> dict:
+ async def get_status(self, dashboard_id: int) -> dict:
return {"sync_state": "SYNCED", "sync_status": "OK"}
monkeypatch.setattr(git_routes, "git_service", SafeBatchGitService())
monkeypatch.setattr(git_routes.os.path, "exists", lambda _path: True)
@@ -164,9 +170,9 @@ def test_commit_changes_applies_profile_identity_before_commit(monkeypatch):
def __init__(self):
self.configured_identity = None
self.commit_payload = None
- def configure_identity(self, dashboard_id: int, git_username: str, git_email: str):
+ async def configure_identity(self, dashboard_id: int, git_username: str, git_email: str):
self.configured_identity = (dashboard_id, git_username, git_email)
- def commit_changes(self, dashboard_id: int, message: str, files):
+ async def commit_changes(self, dashboard_id: int, message: str, files):
self.commit_payload = (dashboard_id, message, files)
class PreferenceRow:
git_username = "user_1"
@@ -189,7 +195,7 @@ def test_commit_changes_applies_profile_identity_before_commit(monkeypatch):
monkeypatch.setattr(
git_routes,
"_resolve_dashboard_id_from_ref",
- lambda *_args, **_kwargs: 12,
+ _resolved_dashboard_id,
)
asyncio.run(
git_routes.commit_changes(
@@ -213,10 +219,10 @@ def test_pull_changes_applies_profile_identity_before_pull(monkeypatch):
def __init__(self):
self.configured_identity = None
self.pulled_dashboard_id = None
- def configure_identity(self, dashboard_id: int, git_username: str, git_email: str):
+ async def configure_identity(self, dashboard_id: int, git_username: str, git_email: str):
self.configured_identity = (dashboard_id, git_username, git_email)
- def pull_changes(self, dashboard_id: int):
- self.pulled_dashboard_id = dashboard_id
+ async def pull_changes(self, dashboard_id: int, pat: str | None = None):
+ self.pulled_payload = (dashboard_id, pat)
class PreferenceRow:
git_username = "user_1"
git_email = "user1@mail.ru"
@@ -235,7 +241,7 @@ def test_pull_changes_applies_profile_identity_before_pull(monkeypatch):
monkeypatch.setattr(
git_routes,
"_resolve_dashboard_id_from_ref",
- lambda *_args, **_kwargs: 12,
+ _resolved_dashboard_id,
)
asyncio.run(
git_routes.pull_changes(
@@ -246,8 +252,127 @@ def test_pull_changes_applies_profile_identity_before_pull(monkeypatch):
)
)
assert identity_service.configured_identity == (12, "user_1", "user1@mail.ru")
- assert identity_service.pulled_dashboard_id == 12
+ assert identity_service.pulled_payload == (12, None)
# #endregion test_pull_changes_applies_profile_identity_before_pull
+# #region test_push_changes_passes_decrypted_profile_pat [TYPE Function]
+# @RELATION BINDS_TO -> TestGitStatusRoute
+# @BRIEF Ensure push route decrypts current user's profile PAT and passes it to GitService.
+# @PRE Profile preference contains encrypted git_personal_access_token.
+# @POST git_service.push_changes receives the decrypted token via pat keyword.
+def test_push_changes_passes_decrypted_profile_pat(monkeypatch):
+ class PushGitService:
+ def __init__(self):
+ self.pushed_payload = None
+ async def push_changes(self, dashboard_id: int, pat: str | None = None):
+ self.pushed_payload = (dashboard_id, pat)
+ class PreferenceRow:
+ git_personal_access_token_encrypted = "encrypted-token"
+ class PreferenceQuery:
+ def filter(self, *_args, **_kwargs):
+ return self
+ def first(self):
+ return PreferenceRow()
+ class DbStub:
+ def query(self, _model):
+ return PreferenceQuery()
+ class UserStub:
+ id = "u-1"
+ class EncryptionStub:
+ def decrypt(self, encrypted_data: str) -> str:
+ assert encrypted_data == "encrypted-token"
+ return "profile-pat"
+ push_service = PushGitService()
+ monkeypatch.setattr(git_routes, "git_service", push_service)
+ monkeypatch.setattr(git_helpers, "EncryptionManager", lambda: EncryptionStub())
+ monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", _resolved_dashboard_id)
+ asyncio.run(
+ git_routes.push_changes(
+ "dashboard-12",
+ config_manager=MagicMock(),
+ db=DbStub(),
+ current_user=UserStub(),
+ )
+ )
+ assert push_service.pushed_payload == (12, "profile-pat")
+# #endregion test_push_changes_passes_decrypted_profile_pat
+# #region test_pull_changes_passes_decrypted_profile_pat [TYPE Function]
+# @RELATION BINDS_TO -> TestGitStatusRoute
+# @BRIEF Ensure pull route decrypts current user's profile PAT and passes it to GitService.
+# @PRE Profile preference contains git identity and encrypted PAT.
+# @POST git_service.pull_changes receives decrypted token after identity configuration.
+def test_pull_changes_passes_decrypted_profile_pat(monkeypatch):
+ class PullGitService:
+ def __init__(self):
+ self.configured_identity = None
+ self.pulled_payload = None
+ async def configure_identity(self, dashboard_id: int, git_username: str, git_email: str):
+ self.configured_identity = (dashboard_id, git_username, git_email)
+ async def pull_changes(self, dashboard_id: int, pat: str | None = None):
+ self.pulled_payload = (dashboard_id, pat)
+ class PreferenceRow:
+ git_username = "user_1"
+ git_email = "user1@mail.ru"
+ git_personal_access_token_encrypted = "encrypted-token"
+ class PreferenceQuery:
+ def filter(self, *_args, **_kwargs):
+ return self
+ def first(self):
+ return PreferenceRow()
+ class DbStub:
+ def query(self, _model):
+ return PreferenceQuery()
+ class UserStub:
+ id = "u-1"
+ class EncryptionStub:
+ def decrypt(self, encrypted_data: str) -> str:
+ assert encrypted_data == "encrypted-token"
+ return "profile-pat"
+ pull_service = PullGitService()
+ monkeypatch.setattr(git_routes, "git_service", pull_service)
+ monkeypatch.setattr(git_helpers, "EncryptionManager", lambda: EncryptionStub())
+ monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", _resolved_dashboard_id)
+ asyncio.run(
+ git_routes.pull_changes(
+ "dashboard-12",
+ config_manager=MagicMock(),
+ db=DbStub(),
+ current_user=UserStub(),
+ )
+ )
+ assert pull_service.configured_identity == (12, "user_1", "user1@mail.ru")
+ assert pull_service.pulled_payload == (12, "profile-pat")
+# #endregion test_pull_changes_passes_decrypted_profile_pat
+# #region test_git_sync_mixin_embeds_and_redacts_pat [TYPE Function]
+# @RELATION BINDS_TO -> TestGitStatusRoute
+# @BRIEF Ensure PAT embedding URL-encodes secrets and redaction removes them from messages.
+# @PRE Origin remote uses an HTTPS URL and PAT contains URL-sensitive characters.
+# @POST Origin URL receives encoded PAT; redaction hides raw and URL-shaped tokens.
+def test_git_sync_mixin_embeds_and_redacts_pat():
+ class OriginStub:
+ def __init__(self):
+ self.urls = ["https://github.com/org/repo.git"]
+ self.set_urls = []
+ def set_url(self, url: str):
+ self.set_urls.append(url)
+ self.urls = [url]
+ class RepoStub:
+ def __init__(self):
+ self.origin = OriginStub()
+ def remote(self, name: str):
+ assert name == "origin"
+ return self.origin
+ repo = RepoStub()
+ original_url = GitServiceSyncMixin._embed_pat_in_origin_url(repo, "tok@:/secret")
+ assert original_url == "https://github.com/org/repo.git"
+ assert repo.origin.set_urls == ["https://git-user:tok%40%3A%2Fsecret@github.com/org/repo.git"]
+ redacted = GitServiceSyncMixin._redact_pat_from_message(
+ "fatal https://git-user:tok%40%3A%2Fsecret@github.com/org/repo.git tok@:/secret",
+ "tok@:/secret",
+ )
+ assert "tok@:/secret" not in redacted
+ assert "tok%40%3A%2Fsecret" not in redacted
+ assert "***" in redacted
+# #endregion test_git_sync_mixin_embeds_and_redacts_pat
# #region test_get_merge_status_returns_service_payload [TYPE Function]
# @RELATION BINDS_TO -> TestGitStatusRoute
# @BRIEF Ensure merge status route returns service payload as-is.
@@ -255,7 +380,7 @@ def test_pull_changes_applies_profile_identity_before_pull(monkeypatch):
# @POST Route response contains has_unfinished_merge=True.
def test_get_merge_status_returns_service_payload(monkeypatch):
class MergeStatusGitService:
- def get_merge_status(self, dashboard_id: int) -> dict:
+ async def get_merge_status(self, dashboard_id: int) -> dict:
return {
"has_unfinished_merge": True,
"repository_path": "/tmp/repo-12",
@@ -266,7 +391,7 @@ def test_get_merge_status_returns_service_payload(monkeypatch):
"conflicts_count": 2,
}
monkeypatch.setattr(git_routes, "git_service", MergeStatusGitService())
- monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", lambda *_args, **_kwargs: 12)
+ monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", _resolved_dashboard_id)
response = asyncio.run(
git_routes.get_merge_status(
"dashboard-12",
@@ -284,7 +409,7 @@ def test_get_merge_status_returns_service_payload(monkeypatch):
def test_resolve_merge_conflicts_passes_resolution_items_to_service(monkeypatch):
captured = {}
class MergeResolveGitService:
- def resolve_merge_conflicts(self, dashboard_id: int, resolutions):
+ async def resolve_merge_conflicts(self, dashboard_id: int, resolutions):
captured["dashboard_id"] = dashboard_id
captured["resolutions"] = resolutions
return ["dashboards/a.yaml"]
@@ -294,7 +419,7 @@ def test_resolve_merge_conflicts_passes_resolution_items_to_service(monkeypatch)
return {"file_path": "dashboards/a.yaml", "resolution": "mine", "content": None}
resolutions = [_Resolution()]
monkeypatch.setattr(git_routes, "git_service", MergeResolveGitService())
- monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", lambda *_args, **_kwargs: 12)
+ monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", _resolved_dashboard_id)
response = asyncio.run(
git_routes.resolve_merge_conflicts(
"dashboard-12",
@@ -313,11 +438,11 @@ def test_resolve_merge_conflicts_passes_resolution_items_to_service(monkeypatch)
# @POST Route returns aborted status.
def test_abort_merge_calls_service_and_returns_result(monkeypatch):
class AbortGitService:
- def abort_merge(self, dashboard_id: int):
+ async def abort_merge(self, dashboard_id: int):
assert dashboard_id == 12
return {"status": "aborted"}
monkeypatch.setattr(git_routes, "git_service", AbortGitService())
- monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", lambda *_args, **_kwargs: 12)
+ monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", _resolved_dashboard_id)
response = asyncio.run(
git_routes.abort_merge(
"dashboard-12",
@@ -333,14 +458,14 @@ def test_abort_merge_calls_service_and_returns_result(monkeypatch):
# @POST Route returns committed status and hash.
def test_continue_merge_passes_message_and_returns_commit(monkeypatch):
class ContinueGitService:
- def continue_merge(self, dashboard_id: int, message: str):
+ async def continue_merge(self, dashboard_id: int, message: str):
assert dashboard_id == 12
assert message == "Resolve all conflicts"
return {"status": "committed", "commit_hash": "abc123"}
class ContinueData:
message = "Resolve all conflicts"
monkeypatch.setattr(git_routes, "git_service", ContinueGitService())
- monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", lambda *_args, **_kwargs: 12)
+ monkeypatch.setattr(git_routes, "_resolve_dashboard_id_from_ref", _resolved_dashboard_id)
response = asyncio.run(
git_routes.continue_merge(
"dashboard-12",
diff --git a/backend/src/api/routes/agent_superset_explore.py b/backend/src/api/routes/agent_superset_explore.py
index de59c4931..8cb3e181d 100644
--- a/backend/src/api/routes/agent_superset_explore.py
+++ b/backend/src/api/routes/agent_superset_explore.py
@@ -17,11 +17,13 @@ router = APIRouter(prefix="/api/agent/superset", tags=["Agent Superset"])
async def _get_superset_client(environment_id: str) -> SupersetClient:
"""Resolve a SupersetClient for the given environment_id."""
- from ..api.routes.environments import _get_environment_by_id as _resolve_env
- env_data = await _resolve_env(environment_id)
- if not env_data:
+ from src.dependencies import get_config_manager
+
+ env = get_config_manager().get_environment(environment_id)
+ if env is None:
raise HTTPException(status_code=404, detail=f"Environment '{environment_id}' not found.")
- env = Environment(**env_data)
+ if not isinstance(env, Environment):
+ env = Environment.model_validate(env)
return SupersetClient(env)
diff --git a/backend/src/api/routes/environments.py b/backend/src/api/routes/environments.py
index 069eb9758..b0b4f7101 100644
--- a/backend/src/api/routes/environments.py
+++ b/backend/src/api/routes/environments.py
@@ -56,6 +56,25 @@ class DatabaseResponse(BaseModel):
engine: str | None
# #endregion DatabaseResponse
+
+# #region _get_environment_by_id [TYPE Function]
+# @BRIEF Legacy resolver for agent Superset routes that still import environment by id.
+# @PRE Environment id can be an id or configured environment name.
+# @POST Returns full Environment-compatible dict or None when missing.
+# @RATIONALE Keeps hot-reloaded/stale agent route code from failing while the direct
+# ConfigManager resolver path is deployed in agent_superset_explore.py.
+async def _get_environment_by_id(environment_id: str) -> dict | None:
+ env = get_config_manager().get_environment(environment_id)
+ if env is None:
+ return None
+ if hasattr(env, "model_dump"):
+ return env.model_dump()
+ if hasattr(env, "dict"):
+ return env.dict()
+ return dict(env)
+# #endregion _get_environment_by_id
+
+
# #region get_environments [TYPE Function] [SEMANTICS list, environments, config]
# @ingroup Api
# @BRIEF List all configured environments.
diff --git a/backend/src/api/routes/git/_helpers.py b/backend/src/api/routes/git/_helpers.py
index ed153c521..4ba6d0768 100644
--- a/backend/src/api/routes/git/_helpers.py
+++ b/backend/src/api/routes/git/_helpers.py
@@ -11,6 +11,7 @@ import os
from fastapi import HTTPException
from sqlalchemy.orm import Session
+from src.core.encryption import EncryptionManager
from src.core.logger import logger
from src.core.superset_client import SupersetClient
from src.models.auth import User
@@ -313,6 +314,51 @@ def _resolve_current_user_git_identity(
# #endregion _resolve_current_user_git_identity
+# #region _resolve_current_user_git_token [C:2] [TYPE Function]
+# @BRIEF Resolve and decrypt the Git personal access token from current user's profile preferences.
+def _resolve_current_user_git_token(
+ db: Session,
+ current_user: User | None,
+) -> str | None:
+ if db is None or not hasattr(db, "query"):
+ return None
+
+ user_id = _sanitize_optional_identity_value(getattr(current_user, "id", None))
+ if not user_id:
+ return None
+
+ try:
+ preference = (
+ db.query(UserDashboardPreference)
+ .filter(UserDashboardPreference.user_id == user_id)
+ .first()
+ )
+ except Exception as resolve_error:
+ logger.explore(
+ "Failed to load profile preference for resolving git PAT",
+ extra={"src": "_resolve_current_user_git_token", "payload": {"user_id": user_id}, "error": str(resolve_error)},
+ )
+ return None
+
+ if not preference:
+ return None
+
+ encrypted_token = getattr(preference, "git_personal_access_token_encrypted", None)
+ if not encrypted_token:
+ return None
+
+ try:
+ encryption = EncryptionManager()
+ return encryption.decrypt(encrypted_token)
+ except Exception as decrypt_error:
+ logger.explore(
+ "Failed to decrypt git PAT from profile",
+ extra={"src": "_resolve_current_user_git_token", "user_id": user_id, "error": str(decrypt_error)},
+ )
+ return None
+# #endregion _resolve_current_user_git_token
+
+
# #region _apply_git_identity_from_profile [C:2] [TYPE Function]
# @BRIEF Apply user-scoped Git identity to repository-local config before write/pull operations.
async def _apply_git_identity_from_profile(
diff --git a/backend/src/api/routes/git/_repo_operations_routes.py b/backend/src/api/routes/git/_repo_operations_routes.py
index 2000f9b49..13064f770 100644
--- a/backend/src/api/routes/git/_repo_operations_routes.py
+++ b/backend/src/api/routes/git/_repo_operations_routes.py
@@ -25,6 +25,7 @@ from ._helpers import (
_apply_git_identity_from_profile,
_build_no_repo_status_payload,
_handle_unexpected_git_route_error,
+ _resolve_current_user_git_token,
_resolve_repository_status,
)
from ._router import router
@@ -105,6 +106,8 @@ async def push_changes(
dashboard_ref: str,
env_id: str | None = None,
config_manager=Depends(get_config_manager),
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
@@ -115,7 +118,8 @@ async def push_changes(
dashboard_id = await _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
- await _gs.push_changes(dashboard_id)
+ pat = _resolve_current_user_git_token(db, current_user)
+ await _gs.push_changes(dashboard_id, pat=pat)
return {"status": "success"}
except HTTPException:
raise
@@ -178,7 +182,8 @@ async def pull_changes(
extra={"src": "pull_changes"},
)
await _apply_git_identity_from_profile(dashboard_id, db, current_user)
- await _gs.pull_changes(dashboard_id)
+ pat = _resolve_current_user_git_token(db, current_user)
+ await _gs.pull_changes(dashboard_id, pat=pat)
return {"status": "success"}
except HTTPException:
raise
diff --git a/backend/src/services/git/_sync.py b/backend/src/services/git/_sync.py
index 8012c801b..7baf9d38c 100644
--- a/backend/src/services/git/_sync.py
+++ b/backend/src/services/git/_sync.py
@@ -6,6 +6,7 @@
# @RELATION DEPENDS_ON -> [GitServiceUrlMixin]
import os
+import re
from fastapi import HTTPException
from git.exc import GitCommandError
@@ -19,11 +20,49 @@ from src.models.git import GitRepository, GitServerConfig
# @defgroup Services Module group.
# @BRIEF Mixin providing push and pull operations with origin host alignment.
class GitServiceSyncMixin:
+ @staticmethod
+ def _redact_pat_from_message(message: str, pat: str | None = None) -> str:
+ redacted = str(message or "")
+ if pat:
+ redacted = redacted.replace(pat, "***")
+ return re.sub(r"(https?://[^\s:/@]+:)[^\s@]+(@)", r"\1***\2", redacted)
+
# region push_changes [C:4] [TYPE Function] [SEMANTICS git,push,lock]
# @PURPOSE: Push local commits to remote (concurrent-safe).
# @PRE Repository exists and has an 'origin' remote.
# @POST Local branch commits are pushed to origin.
- async def push_changes(self, dashboard_id: int):
+ # region _embed_pat_in_origin_url [C:2] [TYPE Function] [SEMANTICS git,auth,pat,url]
+ # @BRIEF Temporarily embed a personal access token into the origin remote URL.
+ # @POST Origin URL is updated with embedded PAT; returns the original URL for restoration.
+ @staticmethod
+ def _embed_pat_in_origin_url(repo, pat: str) -> str | None:
+ from urllib.parse import urlparse, urlunparse, quote
+
+ try:
+ origin = repo.remote(name="origin")
+ original_url = list(origin.urls)[0]
+ except Exception:
+ return None
+ parsed = urlparse(original_url)
+ if parsed.scheme not in ("https", "http"):
+ return None
+ username = parsed.username or "git-user"
+ encoded_pat = quote(pat, safe="")
+ auth_part = f"{quote(username, safe='')}:{encoded_pat}@"
+ host_part = parsed.hostname or ""
+ if parsed.port:
+ host_part = f"{host_part}:{parsed.port}"
+ new_url = urlunparse(
+ (parsed.scheme, f"{auth_part}{host_part}", parsed.path, parsed.params, parsed.query, parsed.fragment)
+ )
+ try:
+ origin.set_url(new_url)
+ return original_url
+ except Exception:
+ return None
+ # endregion _embed_pat_in_origin_url
+
+ async def push_changes(self, dashboard_id: int, pat: str | None = None):
with self._locked(dashboard_id):
with belief_scope("GitService.push_changes"):
repo = await self.get_repo(dashboard_id)
@@ -82,42 +121,56 @@ class GitServiceSyncMixin:
f"Push diagnostics dashboard={dashboard_id} config_id={binding_config_id} config_url={binding_config_url} binding_remote_url={binding_remote_url} origin_urls={origin_urls} origin_realigned={bool(realigned_origin_url)}",
extra={"src": "push_changes"},
)
+ # ── Embed PAT credential if provided ──────────────────────────────
+ _original_push_url: str | None = None
+ if pat:
+ _original_push_url = self._embed_pat_in_origin_url(repo, pat)
+ # ──────────────────────────────────────────────────────────────────
try:
- current_branch = repo.active_branch
- logger.reason(f"Pushing branch {current_branch.name} to origin", extra={"src": "push_changes"})
- tracking_branch = None
try:
- tracking_branch = current_branch.tracking_branch()
- except Exception:
+ current_branch = repo.active_branch
+ logger.reason(f"Pushing branch {current_branch.name} to origin", extra={"src": "push_changes"})
tracking_branch = None
- if tracking_branch is None:
- repo.git.push("--set-upstream", "origin", f"{current_branch.name}:{current_branch.name}")
- else:
- push_info = origin.push(refspec=f'{current_branch.name}:{current_branch.name}')
- for info in push_info:
- if info.flags & info.ERROR:
- logger.explore("Error pushing ref", extra={"src": "push_changes"}, payload={"ref": info.remote_ref_string}, error=str(info.summary))
- raise Exception(f"Git push error for {info.remote_ref_string}: {info.summary}")
- except GitCommandError as e:
- details = str(e)
- lowered = details.lower()
- if "non-fast-forward" in lowered or "rejected" in lowered:
- raise HTTPException(
- status_code=409,
- detail="Push rejected: remote branch contains newer commits. Run Pull first, resolve conflicts if any, then push again.",
- )
- logger.explore("Failed to push changes", extra={"src": "push_changes"}, error=str(e))
- raise HTTPException(status_code=500, detail=f"Git push failed: {details}")
- except Exception as e:
- logger.explore("Failed to push changes", extra={"src": "push_changes"}, error=str(e))
- raise HTTPException(status_code=500, detail=f"Git push failed: {e!s}")
+ try:
+ tracking_branch = current_branch.tracking_branch()
+ except Exception:
+ tracking_branch = None
+ if tracking_branch is None:
+ repo.git.push("--set-upstream", "origin", f"{current_branch.name}:{current_branch.name}")
+ else:
+ push_info = origin.push(refspec=f'{current_branch.name}:{current_branch.name}')
+ for info in push_info:
+ if info.flags & info.ERROR:
+ safe_summary = self._redact_pat_from_message(str(info.summary), pat)
+ logger.explore("Error pushing ref", extra={"src": "push_changes"}, payload={"ref": info.remote_ref_string}, error=safe_summary)
+ raise Exception(f"Git push error for {info.remote_ref_string}: {safe_summary}")
+ except GitCommandError as e:
+ details = self._redact_pat_from_message(str(e), pat)
+ lowered = details.lower()
+ if "non-fast-forward" in lowered or "rejected" in lowered:
+ raise HTTPException(
+ status_code=409,
+ detail="Push rejected: remote branch contains newer commits. Run Pull first, resolve conflicts if any, then push again.",
+ )
+ logger.explore("Failed to push changes", extra={"src": "push_changes"}, error=details)
+ raise HTTPException(status_code=500, detail=f"Git push failed: {details}")
+ except Exception as e:
+ details = self._redact_pat_from_message(str(e), pat)
+ logger.explore("Failed to push changes", extra={"src": "push_changes"}, error=details)
+ raise HTTPException(status_code=500, detail=f"Git push failed: {details}")
+ finally:
+ if _original_push_url is not None:
+ try:
+ origin.set_url(_original_push_url)
+ except Exception:
+ pass
# endregion push_changes
# region pull_changes [C:4] [TYPE Function] [SEMANTICS git,pull,lock]
# @PURPOSE: Pull changes from remote (concurrent-safe).
# @PRE Repository exists and has an 'origin' remote.
# @POST Changes from origin are pulled and merged into the active branch.
- async def pull_changes(self, dashboard_id: int):
+ async def pull_changes(self, dashboard_id: int, pat: str | None = None):
with self._locked(dashboard_id):
with belief_scope("GitService.pull_changes"):
repo = await self.get_repo(dashboard_id)
@@ -129,49 +182,63 @@ class GitServiceSyncMixin:
extra={"src": "pull_changes"},
)
raise HTTPException(status_code=409, detail=payload)
+ # ── Embed PAT credential if provided ──────────────────────────────
+ _original_pull_url: str | None = None
+ if pat:
+ _original_pull_url = self._embed_pat_in_origin_url(repo, pat)
+ # ──────────────────────────────────────────────────────────────────
+ origin = None
try:
- origin = repo.remote(name='origin')
- current_branch = repo.active_branch.name
try:
- origin_urls = list(origin.urls)
- except Exception:
- origin_urls = []
- logger.reason(
- f"Pull diagnostics dashboard={dashboard_id} repo_path={repo.working_tree_dir} branch={current_branch} origin_urls={origin_urls}",
- extra={"src": "pull_changes"},
- )
- origin.fetch(prune=True)
- remote_ref = f"origin/{current_branch}"
- has_remote_branch = any(ref.name == remote_ref for ref in repo.refs)
- logger.reason(
- f"Pull remote branch check dashboard={dashboard_id} branch={current_branch} remote_ref={remote_ref} exists={has_remote_branch}",
- extra={"src": "pull_changes"},
- )
- if not has_remote_branch:
- raise HTTPException(
- status_code=409,
- detail=f"Remote branch '{current_branch}' does not exist yet. Push this branch first.",
+ origin = repo.remote(name='origin')
+ current_branch = repo.active_branch.name
+ try:
+ origin_urls = [self._redact_pat_from_message(url, pat) for url in origin.urls]
+ except Exception:
+ origin_urls = []
+ logger.reason(
+ f"Pull diagnostics dashboard={dashboard_id} repo_path={repo.working_tree_dir} branch={current_branch} origin_urls={origin_urls}",
+ extra={"src": "pull_changes"},
)
- logger.reason(f"Pulling changes from origin/{current_branch}", extra={"src": "pull_changes"})
- repo.git.pull("--no-rebase", "origin", current_branch)
- except ValueError:
- logger.explore("Remote 'origin' not found", extra={"src": "pull_changes"}, payload={"dashboard_id": dashboard_id})
- raise HTTPException(status_code=400, detail="Remote 'origin' not configured")
- except GitCommandError as e:
- details = str(e)
- lowered = details.lower()
- if "conflict" in lowered or "not possible to fast-forward" in lowered:
- raise HTTPException(
- status_code=409,
- detail="Pull requires conflict resolution. Resolve conflicts in repository and repeat operation.",
+ origin.fetch(prune=True)
+ remote_ref = f"origin/{current_branch}"
+ has_remote_branch = any(ref.name == remote_ref for ref in repo.refs)
+ logger.reason(
+ f"Pull remote branch check dashboard={dashboard_id} branch={current_branch} remote_ref={remote_ref} exists={has_remote_branch}",
+ extra={"src": "pull_changes"},
)
- logger.explore("Failed to pull changes", extra={"src": "pull_changes"}, error=str(e))
- raise HTTPException(status_code=500, detail=f"Git pull failed: {details}")
- except HTTPException:
- raise
- except Exception as e:
- logger.explore("Failed to pull changes", extra={"src": "pull_changes"}, error=str(e))
- raise HTTPException(status_code=500, detail=f"Git pull failed: {e!s}")
+ if not has_remote_branch:
+ raise HTTPException(
+ status_code=409,
+ detail=f"Remote branch '{current_branch}' does not exist yet. Push this branch first.",
+ )
+ logger.reason(f"Pulling changes from origin/{current_branch}", extra={"src": "pull_changes"})
+ repo.git.pull("--no-rebase", "origin", current_branch)
+ except ValueError:
+ logger.explore("Remote 'origin' not found", extra={"src": "pull_changes"}, payload={"dashboard_id": dashboard_id})
+ raise HTTPException(status_code=400, detail="Remote 'origin' not configured")
+ except GitCommandError as e:
+ details = self._redact_pat_from_message(str(e), pat)
+ lowered = details.lower()
+ if "conflict" in lowered or "not possible to fast-forward" in lowered:
+ raise HTTPException(
+ status_code=409,
+ detail="Pull requires conflict resolution. Resolve conflicts in repository and repeat operation.",
+ )
+ logger.explore("Failed to pull changes", extra={"src": "pull_changes"}, error=details)
+ raise HTTPException(status_code=500, detail=f"Git pull failed: {details}")
+ except HTTPException:
+ raise
+ except Exception as e:
+ details = self._redact_pat_from_message(str(e), pat)
+ logger.explore("Failed to pull changes", extra={"src": "pull_changes"}, error=details)
+ raise HTTPException(status_code=500, detail=f"Git pull failed: {details}")
+ finally:
+ if _original_pull_url is not None and origin is not None:
+ try:
+ origin.set_url(_original_pull_url)
+ except Exception:
+ pass
# endregion pull_changes
# #endregion GitServiceSyncMixin
# #endregion GitServiceSyncMixin
diff --git a/frontend/src/lib/components/layout/Navbar.svelte b/frontend/src/lib/components/layout/Navbar.svelte
index 138ad443e..30344fc82 100644
--- a/frontend/src/lib/components/layout/Navbar.svelte
+++ b/frontend/src/lib/components/layout/Navbar.svelte
@@ -8,8 +8,6 @@
@LAYER UI
@RELATION BINDS_TO -> [EXT:frontend:authStore]
@RELATION BINDS_TO -> [EXT:frontend:i18n]
-@RELATION DEPENDS_ON -> [LanguageSwitcher]
-
@UX_STATE: Idle -> Navigation links and settings menus are visible.
@UX_STATE: Authenticated -> User identity and logout action are rendered.
-->
@@ -17,7 +15,6 @@
import { onMount } from 'svelte';
import { page } from '$app/state';
import { t } from '$lib/i18n/index.svelte.js';
- import { LanguageSwitcher } from '$lib/ui';
import { auth } from '$lib/auth/store.svelte.js';
import { goto } from '$app/navigation';
import { ROUTES } from '$lib/routes';
@@ -77,8 +74,6 @@
{/if}
-
{$t.profile.description}
+ {/if} {#if loading} -