Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -754,6 +754,16 @@ or extend authorization. If an agent hits missing Lark authorization during a
conversation, the managed `lark-shared` guidance points the user back to the
same settings entry with `?settings=integrations`.

Once configured, **Change Lark app** lets a user point their DeerFlow account at
a different Lark/Feishu app without a reinstall — either by pasting an existing
app's App ID / App Secret or by re-registering an app in the browser. Switching
is per-user (it never touches another user's credentials), validates the new
credentials through the official CLI's live tenant-token probe before replacing
the active app, and revokes/removes the previous app's OAuth tokens. A rejected
credential change does not supersede an in-progress setup or authorization flow.
DeerFlow then immediately opens browser authorization for the newly bound app so
the switch ends in a usable connection.

Installing the Lark skill pack resolves the latest official `larksuite/cli`
release from GitHub and downloads that version's skills at install time, so the
Gateway needs outbound internet access for that step (it falls back to a
Expand Down
2 changes: 1 addition & 1 deletion backend/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -555,7 +555,7 @@ Localhost persistence deliberately reads the direct request `Host` and ignores `
| **Console** (`/api/console`) | Read-only cross-thread observability for the current user (the data layer for an operations dashboard or external monitoring): `GET /stats` - headline counters (runs/threads/agents/tokens/cost); `GET /runs` - paginated run history joined with thread titles (per-run cost); `GET /usage` - zero-filled daily token series + per-model breakdown with spend. Queries `runs`/`threads_meta` directly as a reporting layer (no new `RunStore` methods); requires a SQL database backend — returns 503 on `database.backend: memory`. Real-cost estimation reads optional `models[*].pricing` (`currency`, `input_per_million`, `output_per_million`, `input_cache_hit_per_million`; `ModelConfig` is `extra="allow"`, so no schema change) and prices each run from its `token_usage_by_model` input/output split. Pricing is **cache-aware**: `RunJournal` accumulates prompt-cache hits from `usage_metadata.input_token_details.cache_read` into a sparse `cache_read_tokens` bucket key (also threaded through `SubagentTokenCollector` → `record_external_llm_usage_records`), and cache-hit input tokens are billed at `input_cache_hit_per_million` (omitted → billed at the miss price, a conservative upper bound). All priced models must use one currency; mixed currencies disable cost reporting and leave cost/currency fields null instead of producing invalid aggregates. Legacy rows fall back to run-level totals at `model_name`; unpriced models yield `cost: null` and cost fields are null when no pricing is configured |
| **MCP** (`/api/mcp`) | `GET /config` - get config; `PUT /config` - replace the full config with whole-payload stdio validation; `PATCH /config` - toggle one server while preserving the raw extensions config and validating only an enabled target; both writes reload config and reset the process-local MCP cache |
| **Skills** (`/api/skills`) | `GET /` - list skills; `GET /{name}` - details; `PUT /{name}` - update enabled; `POST /install` - install from .skill archive (accepts standard optional frontmatter like `version`, `author`, `compatibility`); `POST /reload` - admin-only process-local prompt-cache invalidation after trusted external filesystem changes |
| **Integrations** (`/api/integrations`) | `GET /lark/status` - inspect managed Lark/Feishu CLI integration state, including `sandbox_runtime_mode` / `sandbox_runtime_ready` (whether `lark-cli` will actually be present in the sandbox at chat time); `POST /lark/install` - admin-only install of the official `lark-*` managed skill pack; `POST /lark/config/start` and `/lark/config/complete` - internal first-time Lark connection setup; `POST /lark/auth/start` and `/lark/auth/complete` - browser device-flow user authorization without terminal access, with optional `domains` / exact `scope` for incremental permission grants |
| **Integrations** (`/api/integrations`) | `GET /lark/status` - inspect managed Lark/Feishu CLI integration state, including `sandbox_runtime_mode` / `sandbox_runtime_ready` (whether `lark-cli` will actually be present in the sandbox at chat time); `POST /lark/install` - admin-only install of the official `lark-*` managed skill pack; `POST /lark/config/start` and `/lark/config/complete` - internal first-time Lark connection setup; `POST /lark/config/credentials` - atomically switch the caller's per-user Lark app after validating the new `app_id`/`app_secret` through the official CLI's live tenant-token probe, revoke/remove the previous OAuth tokens, and restore the prior credential tree if the switch fails; `POST /lark/auth/start` and `/lark/auth/complete` - browser device-flow user authorization without terminal access, with optional `domains` / exact `scope` for incremental permission grants. Config and auth flows carry a server-issued, per-user generation persisted under the credential lock; a rejected direct switch leaves the current generation unchanged, stale completions return 409, and browser re-registration uses the same token-clearing/revocation transaction as direct credential switches. |
| **Memory** (`/api/memory`) | `GET /` - memory data; `POST /reload` - force reload; `GET /config` - config; `GET /status` - config + data |
| **Uploads** (`/api/threads/{id}/uploads`) | `POST /` - upload files (auto-converts PDF/PPT/Excel/Word); `GET /list` - list; `DELETE /{filename}` - delete |
| **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; `POST /branches` - create a new main-thread branch from a completed assistant turn checkpoint and, when an addressable pre-user replay checkpoint exists, materialize it into the branch namespace so the inherited response remains regeneratable. Workspace files are not checkpointed, so the branch only best-effort copies the current workspace when branching from the **latest** turn (`workspace_clone_mode="current_thread_best_effort"`); branching from an older/historical turn skips the copy (`workspace_clone_mode="skipped_historical_turn"`) so the branch never inherits files that only exist in a later timeline. Thread-scoped runtime channels (`sandbox`, `thread_data`) are not copied onto the branch: the parent's `sandbox_id` binds path mappings and the release lifecycle to the parent's workspace, so the branch lazily acquires its own sandbox instead. Branch creation also seeds the new thread's run-event feed from the branch checkpoint's visible messages (`history_seed_mode` in the response): the thread feed reads run_events, not checkpoints, so without the seed the inherited history disappears from the UI after the branch's first run (#4380). Seeded rows are grouped into one synthetic run per inherited turn (`branch-seed-{thread_id}-{n}`, a new turn opening at every persisted human message, including an allowlisted hidden `ask_clarification` reply) because `run_id` is a turn identity to the feed's consumers, not a provenance tag: regenerating an inherited answer supersedes that row's whole `run_id` in `GET /messages/page`, so one shared id for the entire seed deleted the complete inherited history on a branch's first regenerate (#4458); `GET /goal`, `PUT /goal`, `DELETE /goal` - read, set, and clear the active thread goal; `POST /compact` - manually summarize older active context into `summary_text` and retain the recent message window, blocked while a run is in flight; unexpected failures are logged server-side and return a generic 500 detail |
Expand Down
50 changes: 50 additions & 0 deletions backend/app/gateway/routers/integrations.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import asyncio
import logging
from typing import Literal

from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
Expand All @@ -17,12 +18,14 @@
LarkCliProbe,
LarkConfigCompleteResult,
LarkConfigStartResult,
LarkFlowSupersededError,
LarkInstallResult,
LarkIntegrationStatus,
complete_lark_auth,
complete_lark_config,
get_lark_integration_status,
install_lark_integration,
set_lark_app_credentials,
start_lark_auth,
start_lark_config,
)
Expand Down Expand Up @@ -94,6 +97,7 @@ class LarkAuthStartRequest(BaseModel):
recommend: bool = Field(default=False, description="Request the official recommended auto-approve scopes")
domains: list[str] = Field(default_factory=list, description="Optional Lark auth domains, e.g. calendar or docs")
scope: str | None = Field(default=None, description="Optional explicit OAuth scope string")
generation: str | None = Field(default=None, min_length=1, max_length=64, description="Optional current integration flow generation")


class LarkConfigStartRequest(BaseModel):
Expand All @@ -103,6 +107,7 @@ class LarkConfigStartRequest(BaseModel):
class LarkConfigStartResponse(BaseModel):
verification_url: str = Field(..., description="URL the user should open in a browser to configure the Lark app")
device_code: str = Field(..., description="Device code used by config/complete after browser approval")
generation: str = Field(..., description="Server generation bound to this configuration flow")
expires_in: int | None = Field(None, description="Seconds before the configuration URL expires")
interval: int | None = Field(None, description="Suggested polling interval from Lark")
user_code: str | None = Field(None, description="Optional user code shown by Lark")
Expand All @@ -111,6 +116,7 @@ class LarkConfigStartResponse(BaseModel):

class LarkConfigCompleteRequest(BaseModel):
device_code: str = Field(..., description="Device code returned by config/start")
generation: str = Field(..., min_length=1, max_length=64, description="Generation returned by config/start")
brand: str = Field(default="feishu", description="Brand returned by config/start")
interval: int | None = Field(default=None, description="Polling interval returned by config/start")
expires_in: int | None = Field(default=None, description="Expiration returned by config/start")
Expand All @@ -119,19 +125,28 @@ class LarkConfigCompleteRequest(BaseModel):
class LarkConfigCompleteResponse(BaseModel):
success: bool
message: str
generation: str
status: LarkIntegrationStatusResponse


class LarkConfigCredentialsRequest(BaseModel):
app_id: str = Field(..., description="Lark/Feishu App ID to switch this user's integration to")
app_secret: str = Field(..., description="Lark/Feishu App Secret paired with app_id")
brand: Literal["feishu", "lark"] = Field(default="feishu", description="Lark brand: feishu or lark")


class LarkAuthStartResponse(BaseModel):
verification_url: str = Field(..., description="URL the user should open in a browser to authorize")
device_code: str = Field(..., description="Device code used by the complete endpoint after browser approval")
generation: str = Field(..., description="Server generation bound to this authorization flow")
expires_in: int | None = Field(None, description="Seconds before the authorization URL expires")
user_code: str | None = Field(None, description="Optional user code shown by Lark")
hint: str | None = Field(None, description="Optional guidance returned by lark-cli")


class LarkAuthCompleteRequest(BaseModel):
device_code: str = Field(..., description="Device code returned by auth/start")
generation: str = Field(..., min_length=1, max_length=64, description="Generation returned by auth/start")
wait_timeout_seconds: int = Field(
default=LARK_AUTH_COMPLETE_DEFAULT_WAIT_SECONDS,
ge=LARK_AUTH_COMPLETE_MIN_WAIT_SECONDS,
Expand Down Expand Up @@ -205,6 +220,7 @@ def _config_start_to_response(result: LarkConfigStartResult) -> LarkConfigStartR
return LarkConfigStartResponse(
verification_url=result.verification_url,
device_code=result.device_code,
generation=result.generation,
expires_in=result.expires_in,
interval=result.interval,
user_code=result.user_code,
Expand All @@ -216,6 +232,7 @@ def _config_complete_to_response(result: LarkConfigCompleteResult, *, include_ho
return LarkConfigCompleteResponse(
success=result.success,
message=result.message,
generation=result.generation,
status=_status_to_response(result.status, include_host_paths=include_host_paths),
)

Expand All @@ -224,6 +241,7 @@ def _auth_start_to_response(result: LarkAuthStartResult) -> LarkAuthStartRespons
return LarkAuthStartResponse(
verification_url=result.verification_url,
device_code=result.device_code,
generation=result.generation,
expires_in=result.expires_in,
user_code=result.user_code,
hint=result.hint,
Expand Down Expand Up @@ -294,13 +312,16 @@ async def complete_lark_app_config(request: Request, body: LarkConfigCompleteReq
get_effective_user_id(),
config,
device_code=body.device_code,
generation=body.generation,
brand=body.brand,
interval=body.interval,
expires_in=body.expires_in,
)
return _config_complete_to_response(result, include_host_paths=await _is_admin_user(request))
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except LarkFlowSupersededError as e:
raise HTTPException(status_code=409, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except TimeoutError as e:
Expand All @@ -310,6 +331,29 @@ async def complete_lark_app_config(request: Request, body: LarkConfigCompleteReq
raise HTTPException(status_code=500, detail="Failed to complete Lark connection setup.")


@router.post("/lark/config/credentials", response_model=LarkConfigCompleteResponse, summary="Switch Lark/Feishu App Credentials")
async def switch_lark_app_credentials(request: Request, body: LarkConfigCredentialsRequest, config: AppConfig = Depends(get_config)) -> LarkConfigCompleteResponse:
try:
result = await asyncio.to_thread(
set_lark_app_credentials,
get_effective_user_id(),
config,
app_id=body.app_id,
app_secret=body.app_secret,
brand=body.brand,
)
return _config_complete_to_response(result, include_host_paths=await _is_admin_user(request))
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except TimeoutError as e:
raise HTTPException(status_code=504, detail=str(e))
except Exception as e:
logger.error("Failed to switch Lark app credentials: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to switch Lark app credentials.")


@router.post("/lark/auth/start", response_model=LarkAuthStartResponse, summary="Start Lark/Feishu Browser Authorization")
async def start_lark_browser_auth(body: LarkAuthStartRequest) -> LarkAuthStartResponse:
try:
Expand All @@ -319,10 +363,13 @@ async def start_lark_browser_auth(body: LarkAuthStartRequest) -> LarkAuthStartRe
domains=tuple(body.domains),
scope=body.scope,
recommend=body.recommend,
generation=body.generation,
)
return _auth_start_to_response(result)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except LarkFlowSupersededError as e:
raise HTTPException(status_code=409, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except TimeoutError as e:
Expand All @@ -340,11 +387,14 @@ async def complete_lark_browser_auth(request: Request, body: LarkAuthCompleteReq
get_effective_user_id(),
config,
device_code=body.device_code,
generation=body.generation,
wait_timeout_seconds=body.wait_timeout_seconds,
)
return _auth_complete_to_response(result, include_host_paths=await _is_admin_user(request))
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except LarkFlowSupersededError as e:
raise HTTPException(status_code=409, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except TimeoutError as e:
Expand Down
Loading
Loading