open-swe/ui/src/lib/api.ts
Adam Moussa 5350b63c85
Some checks are pending
CI / Lint (push) Waiting to run
CI / Format check (push) Waiting to run
CI / Typecheck (push) Waiting to run
CI / Unit tests (push) Waiting to run
CI / Playwright E2E (push) Waiting to run
CI / Docker build smoke (push) Waiting to run
CI / Triage ledger up to date (push) Waiting to run
CI / ui bun.lock in sync (push) Waiting to run
feat(open-swe): re-add Fable 5 behind an admin toggle (Bedrock) (#172)
* feat(models): re-add Fable 5 with admin disable toggle (port of upstream #1677)

* refactor(models): convert re-added Fable 5 to Bedrock model IDs

* fix(open-swe): correct Fable copy to describe provider data sharing, not ZDR

The ported admin toggle description and code comments described Fable 5 as
incompatible with Zero Data Retention. That is backwards: Fable 5 requires
the account to opt into Bedrock provider_data_share — prompts/completions are
retained and shared with Anthropic (up to 30 days, incl. human review). The
old UI copy would lead an admin to believe the opposite of what enabling the
toggle does. Reword the toggle description and the gate_fable_model /
team_settings comments accordingly. Still off by default. Refs #171.
2026-07-10 14:05:20 -04:00

843 lines
23 KiB
TypeScript

/**
* Typed client for the open-swe dashboard backend.
*
* All requests are sent with credentials so the httpOnly `osw_session`
* cookie set by the OAuth callback rides along on cross-origin calls.
*/
const API_BASE = (import.meta.env.VITE_DASHBOARD_API_BASE_URL ?? "").replace(
/\/$/,
""
)
if (!API_BASE && typeof window !== "undefined") {
console.warn("VITE_DASHBOARD_API_BASE_URL is not set")
}
const GITHUB_IMAGE_HOST_RE =
/^(?:www\.)?github\.com$|\.githubusercontent\.com$/i
/**
* Build an authenticated proxy URL for GitHub-hosted PR images. Private-repo
* attachments can't be loaded directly by the browser, so they're routed
* through the dashboard backend which holds the App token. Non-GitHub image
* URLs are returned unchanged.
*/
export function reviewImageProxyUrl(
owner: string,
repo: string,
number: number,
src: string
): string {
let parsed: URL
try {
parsed = new URL(src)
} catch {
return src
}
if (
parsed.protocol !== "https:" ||
!GITHUB_IMAGE_HOST_RE.test(parsed.hostname)
) {
return src
}
if (
/^(?:www\.)?github\.com$/i.test(parsed.hostname) &&
!parsed.pathname.startsWith("/user-attachments/")
) {
return src
}
const path = `/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/image`
return `${API_BASE}/dashboard/api${path}?url=${encodeURIComponent(src)}`
}
export class ApiError extends Error {
constructor(
public readonly status: number,
message: string
) {
super(message)
this.name = "ApiError"
}
}
export function isGithubReauthError(error: unknown): boolean {
if (!(error instanceof ApiError)) return false
if (error.status === 401) return true
return /github token|re-login required/i.test(error.message)
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(`${API_BASE}/dashboard/api${path}`, {
...init,
credentials: "include",
headers: {
"Content-Type": "application/json",
...(init.headers ?? {}),
},
})
if (!res.ok) {
let message = res.statusText
try {
const body = await res.json()
if (body?.detail)
message =
typeof body.detail === "string"
? body.detail
: JSON.stringify(body.detail)
} catch {
/* ignore */
}
throw new ApiError(res.status, message)
}
if (res.status === 204) return undefined as T
return (await res.json()) as T
}
export interface PRTraceResolutionResult {
resolved: boolean
detail: string
project: string | null
thread_id: string | null
confidence: number | null
evidence: Array<string>
trace_url: string | null
run_count: number
first_turn: string | null
last_turn: string | null
}
export interface SessionUser {
login: string
email: string | null
avatar_url: string | null
is_admin: boolean
slack_oauth_enabled?: boolean
}
export interface ModelOption {
id: string
label: string
efforts: Array<string>
default_effort: string
supports_images: boolean
}
export interface OptionsPayload {
models: Array<ModelOption>
default_agent_model: string
default_agent_reasoning_effort: string
default_agent_subagent_model: string
default_agent_subagent_reasoning_effort: string
}
export interface Profile {
login?: string
email?: string
default_model?: string
reasoning_effort?: string
default_subagent_model?: string | null
subagent_reasoning_effort?: string | null
default_repo?: string | null
base_branch?: string | null
branch_prefix?: string | null
auto_fix_ci?: boolean
create_prs?: boolean
review_draft_prs?: boolean | null
updated_at?: string
}
export interface ProfileUpdate {
default_model: string
reasoning_effort: string
default_subagent_model?: string | null
subagent_reasoning_effort?: string | null
default_repo?: string | null
base_branch?: string | null
branch_prefix?: string | null
auto_fix_ci?: boolean
create_prs?: boolean
review_draft_prs?: boolean | null
}
export interface TeamSettings {
review_draft_prs: boolean
pr_summaries: boolean
review_trace_links: boolean
/** Tri-state LLM Gateway toggle; null inherits the LANGSMITH_GATEWAY_ENABLED default. */
gateway_enabled?: boolean | null
fable_enabled?: boolean
review_tracing_project?: string | null
org_guidelines?: string | null
default_agent_model?: string | null
default_agent_reasoning_effort?: string | null
default_agent_subagent_model?: string | null
default_agent_subagent_reasoning_effort?: string | null
default_repo?: string | null
default_reviewer_model?: string | null
default_reviewer_reasoning_effort?: string | null
default_reviewer_subagent_model?: string | null
default_reviewer_subagent_reasoning_effort?: string | null
default_grouping_model?: string | null
default_grouping_reasoning_effort?: string | null
default_chat_model?: string | null
default_chat_reasoning_effort?: string | null
updated_at?: string | null
}
export interface ProviderCredentialStatus {
connected: boolean
site?: string
endpoint?: string
api_key_last4?: string
updated_at?: string | null
}
export interface TeamCredentialsStatus {
datadog: ProviderCredentialStatus
langsmith: ProviderCredentialStatus
}
export interface DatadogConnectBody {
site: string
api_key: string
app_key: string
}
export interface LangSmithConnectBody {
api_key: string
endpoint?: string | null
}
export interface CurrentsCredentialStatus {
connected: boolean
api_key_last4?: string
updated_at?: string | null
}
export interface CurrentsConnectBody {
api_key: string
}
export interface NotionCredentialStatus {
connected: boolean
token_expires_at?: string | null
updated_at?: string | null
}
export interface UserMapping {
github_login: string
work_email: string
slack_user_id?: string | null
source?: string
status?: string
created_at?: string
updated_at?: string
}
export interface UserMappingsPage {
items: Array<UserMapping>
total: number
page: number
page_size: number
}
export type UsageLeaderboardPeriod = "7d" | "30d" | "all"
export interface UsageLeaderboardRow {
rank: number
user: {
name: string
github_login: string | null
email: string | null
}
favorite_model: string
agent_runs: number
prs_opened: number
merged_prs: number
agent_loc: number
additions: number
deletions: number
}
export interface ReviewerStatsCounterRow {
name: string
count: number
}
export interface ReviewerStatsPayload {
period: UsageLeaderboardPeriod
reviewed_prs: number
prs_with_findings: number
findings_recorded: number
surfaced_findings: number
addressed_findings: number
resolved_after_update: number
dismissed_findings: number
unresolved_surfaced_findings: number
resolution_rate: number
human_replies: number
severity_counts: Record<string, number>
top_categories: Array<ReviewerStatsCounterRow>
generated_at_ms: number | null
}
export interface UsageLeaderboardPayload {
period: UsageLeaderboardPeriod
rows: Array<UsageLeaderboardRow>
total_members: number
current_user_rank: number | null
generated_at_ms: number | null
reviewer_stats: ReviewerStatsPayload
}
export interface Repository {
full_name: string
private: boolean
}
export interface Installation {
id: number
account: string | null
account_type: string | null
}
export interface ReposPayload {
installations: Array<Installation>
repositories: Array<Repository>
}
export type ReviewStyleStatus = "idle" | "running" | "completed" | "failed"
export interface ReviewStyle {
full_name: string
owner?: string
name?: string
status: ReviewStyleStatus
custom_prompt: string | null
analysis_summary: string | null
top_reviewers: Array<string>
prs_sampled: number
reviews_sampled: number
analysis_thread_id: string | null
analysis_run_id: string | null
error: string | null
created_by?: string
created_at?: string
updated_at?: string
}
export interface AgentInstructions {
full_name: string
owner?: string
name?: string
instructions: string
created_by?: string
created_at?: string
updated_at?: string
}
export type RepoSnapshotStatus = "none" | "building" | "ready" | "failed"
export interface RepoSnapshot {
full_name: string
owner?: string
name?: string
dockerfile: string
snapshot_id: string | null
snapshot_name: string | null
status: RepoSnapshotStatus
status_message: string | null
build_log: string | null
fs_capacity_bytes?: number
vcpus?: number
mem_bytes?: number
target?: string | null
build_args?: Record<string, string> | null
build_started_at?: string | null
last_built_at?: string | null
created_by?: string
created_at?: string
updated_at?: string
}
export interface RepoSnapshotUpdateBody {
dockerfile: string
fs_capacity_bytes?: number | null
vcpus?: number | null
mem_bytes?: number | null
target?: string | null
build_args?: Record<string, string> | null
}
export type FindingSeverity = "low" | "medium" | "high" | "critical"
export type FindingConfidence = "low" | "medium" | "high"
export type FindingStatus = "open" | "resolved" | "dismissed"
export type FindingGroup = "bug" | "investigate" | "informational"
export interface FindingInteraction {
kind: "human_reply" | "bot_reply"
author?: string
body?: string
created_at?: string
}
export interface ReviewFinding {
id: string
severity: FindingSeverity
confidence: FindingConfidence
category: string
title: string
description: string
suggestion: string | null
file: string
start_line: number | null
end_line: number | null
side: "LEFT" | "RIGHT"
in_diff: boolean
status: FindingStatus
outdated: boolean
resolution_note: string | null
diff_hunk: string | null
github_thread_resolved: boolean
github_review_comment_id: number | null
interactions: Array<FindingInteraction>
group: FindingGroup
}
export interface ReviewCommentCreate {
path: string
line: number
side: "LEFT" | "RIGHT"
body: string
start_line?: number | null
start_side?: "LEFT" | "RIGHT" | null
}
export interface ReviewCommentResult {
id: number
html_url: string
}
export interface PrReviewComment {
id: number
author: string
author_avatar_url: string
path: string
line: number | null
side: "LEFT" | "RIGHT"
body: string
html_url: string
created_at: string
is_open_swe: boolean
// Outdated: the line no longer appears in the current diff, so it can't render inline.
is_outdated: boolean
}
export interface ReviewCommentsPayload {
comments: Array<PrReviewComment>
}
export interface ReviewCounts {
open: number
resolved: number
dismissed: number
bugs: number
flags: number
}
export interface ReviewSummary {
thread_id: string
owner: string
repo: string
number: number
title: string
url: string
head_ref: string
base_ref: string
author: string
head_sha: string
watch: boolean
status: "running" | "error" | "idle"
counts: ReviewCounts
updated_at: string | null
full_name?: string
}
export interface ReviewListPayload {
reviews: Array<ReviewSummary>
page: number
has_more: boolean
}
export interface ReviewUserRef {
login: string
avatar_url?: string | null
}
export interface ReviewCheckRun {
name: string
status: string
conclusion: string | null
url: string | null
}
export interface ReviewPrDetails {
state: string
title: string
body: string
additions: number
deletions: number
changed_files: number
commits: number
head_sha: string
head_ref: string
base_ref: string
author: ReviewUserRef | null
assignees: Array<ReviewUserRef>
requested_reviewers: Array<ReviewUserRef>
labels: Array<{ name: string; color: string | null }>
}
export interface ReviewDiffGroup {
index: number
title: string
summary: string
files: Array<string>
}
export interface ReviewDetail extends ReviewSummary {
pr: ReviewPrDetails
checks: Array<ReviewCheckRun>
findings: Array<ReviewFinding>
diff_groups: Array<ReviewDiffGroup>
diff_groups_stale: boolean
}
export interface ReviewDiffFile {
path: string
previousPath: string | null
status: "added" | "removed" | "modified" | "renamed"
additions: number
deletions: number
originalContent: string
modifiedContent: string
unrenderable?: boolean
}
export interface ReviewDiffPayload {
files: Array<ReviewDiffFile>
total_additions: number
total_deletions: number
truncated: boolean
}
export interface ReviewChatMeta {
available: boolean
assistant_id: string
}
export interface ReviewChatThread {
thread_id: string
title: string
updated_at?: string | null
}
/**
* Absolute base URL for the PR chat's LangGraph StreamProvider. The SDK builds
* request URLs as `new URL(apiUrl + path)`, so this must be absolute — a
* same-origin base is promoted using the current origin.
*/
export function reviewChatApiBase(
owner: string,
repo: string,
number: number
): string {
const path = `${API_BASE}/dashboard/api/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/chat`
if (/^https?:\/\//.test(path)) return path
if (typeof window !== "undefined") {
return `${window.location.origin}${path.startsWith("/") ? "" : "/"}${path}`
}
return path
}
export type ReviewerEvalScoreMode = "all_findings" | "surfaced_findings"
export type ReviewerEvalSeverity = "low" | "medium" | "high" | "critical"
export interface ReviewerEvalConfig {
dataset_name: string
experiment_prefix: string
max_concurrency: number
langsmith_project: string
langgraph_url: string
assistant_id: string
model_id: string
reasoning_effort: string
score_mode: ReviewerEvalScoreMode
severity_threshold: ReviewerEvalSeverity
cap: number
}
export interface ReviewerEvalProgress {
completed: number
total: number | null
}
export interface ReviewerEvalStatus {
name: string
status: "idle" | "running" | "completed" | "failed"
run_name?: string
langsmith_project: string
limit: number | null
config_snapshot?: ReviewerEvalConfig
started_at: string | null
finished_at: string | null
created_by: string | null
pid: number | null
exit_code: number | null
experiment_url: string | null
error: string | null
log_tail: string | null
progress?: ReviewerEvalProgress | null
github_run_url?: string | null
trigger?: string | null
updated_at: string
}
export const api = {
me: () => request<SessionUser>("/me"),
options: () => request<OptionsPayload>("/options"),
profile: () => request<Profile>("/profile"),
saveProfile: (body: ProfileUpdate) =>
request<Profile>("/profile", { method: "PUT", body: JSON.stringify(body) }),
repos: () => request<ReposPayload>("/repos"),
listReviewStyles: () => request<Array<ReviewStyle>>("/review-styles"),
createReviewStyle: (full_name: string) =>
request<ReviewStyle>("/review-styles", {
method: "POST",
body: JSON.stringify({ full_name }),
}),
getReviewStyle: (full_name: string) =>
request<ReviewStyle>(`/review-styles/${encodeURIComponent(full_name)}`),
saveReviewStylePrompt: (full_name: string, custom_prompt: string) =>
request<ReviewStyle>(`/review-styles/${encodeURIComponent(full_name)}`, {
method: "PUT",
body: JSON.stringify({ custom_prompt }),
}),
analyzeReviewStyle: (full_name: string) =>
request<ReviewStyle>(
`/review-styles/${encodeURIComponent(full_name)}/analyze`,
{
method: "POST",
}
),
cancelReviewStyle: (full_name: string) =>
request<ReviewStyle>(
`/review-styles/${encodeURIComponent(full_name)}/cancel`,
{
method: "POST",
}
),
deleteReviewStyle: (full_name: string) =>
request<void>(`/review-styles/${encodeURIComponent(full_name)}`, {
method: "DELETE",
}),
listAgentInstructions: () =>
request<Array<AgentInstructions>>("/agent-instructions"),
createAgentInstructions: (full_name: string) =>
request<AgentInstructions>("/agent-instructions", {
method: "POST",
body: JSON.stringify({ full_name }),
}),
getAgentInstructions: (full_name: string) =>
request<AgentInstructions>(
`/agent-instructions/${encodeURIComponent(full_name)}`
),
saveAgentInstructions: (full_name: string, instructions: string) =>
request<AgentInstructions>(
`/agent-instructions/${encodeURIComponent(full_name)}`,
{
method: "PUT",
body: JSON.stringify({ instructions }),
}
),
deleteAgentInstructions: (full_name: string) =>
request<void>(`/agent-instructions/${encodeURIComponent(full_name)}`, {
method: "DELETE",
}),
listRepoSnapshots: () => request<Array<RepoSnapshot>>("/repo-snapshots"),
createRepoSnapshot: (full_name: string) =>
request<RepoSnapshot>("/repo-snapshots", {
method: "POST",
body: JSON.stringify({ full_name }),
}),
getRepoSnapshot: (full_name: string) =>
request<RepoSnapshot>(`/repo-snapshots/${encodeURIComponent(full_name)}`),
getRepoSnapshotTemplate: (full_name: string) =>
request<{ dockerfile: string }>(
`/repo-snapshots/template?full_name=${encodeURIComponent(full_name)}`
),
saveRepoSnapshot: (full_name: string, body: RepoSnapshotUpdateBody) =>
request<RepoSnapshot>(`/repo-snapshots/${encodeURIComponent(full_name)}`, {
method: "PUT",
body: JSON.stringify(body),
}),
buildRepoSnapshot: (full_name: string) =>
request<RepoSnapshot>(
`/repo-snapshots/${encodeURIComponent(full_name)}/build`,
{ method: "POST" }
),
deleteRepoSnapshot: (full_name: string) =>
request<void>(`/repo-snapshots/${encodeURIComponent(full_name)}`, {
method: "DELETE",
}),
getTeamSettings: () => request<TeamSettings>("/team-settings"),
saveTeamSettings: (body: TeamSettings) =>
request<TeamSettings>("/team-settings", {
method: "PUT",
body: JSON.stringify(body),
}),
getTeamCredentials: () => request<TeamCredentialsStatus>("/team-credentials"),
connectDatadog: (body: DatadogConnectBody) =>
request<TeamCredentialsStatus>("/team-credentials/datadog", {
method: "PUT",
body: JSON.stringify(body),
}),
disconnectDatadog: () =>
request<TeamCredentialsStatus>("/team-credentials/datadog", {
method: "DELETE",
}),
connectLangSmith: (body: LangSmithConnectBody) =>
request<TeamCredentialsStatus>("/team-credentials/langsmith", {
method: "PUT",
body: JSON.stringify(body),
}),
disconnectLangSmith: () =>
request<TeamCredentialsStatus>("/team-credentials/langsmith", {
method: "DELETE",
}),
getMyCurrentsStatus: () =>
request<CurrentsCredentialStatus>("/my-credentials/currents"),
connectCurrents: (body: CurrentsConnectBody) =>
request<CurrentsCredentialStatus>("/my-credentials/currents", {
method: "PUT",
body: JSON.stringify(body),
}),
disconnectCurrents: () =>
request<CurrentsCredentialStatus>("/my-credentials/currents", {
method: "DELETE",
}),
getMyNotionStatus: () =>
request<NotionCredentialStatus>("/my-credentials/notion"),
disconnectNotion: () =>
request<NotionCredentialStatus>("/my-credentials/notion", {
method: "DELETE",
}),
listEnabledReviewRepos: () =>
request<{ repos: Array<string> }>("/enabled-review-repos"),
setEnabledReviewRepo: (full_name: string, enabled: boolean) =>
request<{ repos: Array<string> }>("/enabled-review-repos", {
method: "PUT",
body: JSON.stringify({ full_name, enabled }),
}),
usageLeaderboard: (period: UsageLeaderboardPeriod = "30d", limit = 10) =>
request<UsageLeaderboardPayload>(
`/agent-usage-leaderboard?period=${encodeURIComponent(period)}&limit=${limit}`
),
myMapping: () => request<Partial<UserMapping>>("/my-mapping"),
adminListUserMappings: (page = 1, pageSize = 20) =>
request<UserMappingsPage>(
`/admin/user-mappings?page=${page}&page_size=${pageSize}`
),
adminUpsertUserMapping: (input: {
github_login: string
work_email: string
slack_user_id?: string | null
}) =>
request<UserMapping>("/admin/user-mappings", {
method: "POST",
body: JSON.stringify(input),
}),
adminDeleteUserMapping: (github_login: string) =>
request<{ deleted: boolean }>(
`/admin/user-mappings/${encodeURIComponent(github_login)}`,
{ method: "DELETE" }
),
listReviews: (page: number, mine: boolean) =>
request<ReviewListPayload>(`/reviews?page=${page}&mine=${mine}`),
getReview: (owner: string, repo: string, number: number) =>
request<ReviewDetail>(
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}`
),
getReviewDiff: (owner: string, repo: string, number: number) =>
request<ReviewDiffPayload>(
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/diff`
),
getReviewChat: (owner: string, repo: string, number: number) =>
request<ReviewChatMeta>(
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/chat`
),
listReviewChatThreads: (owner: string, repo: string, number: number) =>
request<{ threads: Array<ReviewChatThread> }>(
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/chat/threads`
),
deleteReviewChatThread: (
owner: string,
repo: string,
number: number,
threadId: string
) =>
request<void>(
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/chat/threads/${encodeURIComponent(threadId)}`,
{ method: "DELETE" }
),
reReview: (owner: string, repo: string, number: number) =>
request<{
success: boolean
queued: boolean
thread_id: string
pr_url: string
}>(
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/re-review`,
{ method: "POST" }
),
resolveTrace: (owner: string, repo: string, number: number) =>
request<PRTraceResolutionResult>(
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/resolve-trace`,
{ method: "POST" }
),
createReviewComment: (
owner: string,
repo: string,
number: number,
body: ReviewCommentCreate
) =>
request<ReviewCommentResult>(
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/comments`,
{ method: "POST", body: JSON.stringify(body) }
),
listReviewComments: (owner: string, repo: string, number: number) =>
request<ReviewCommentsPayload>(
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/comments`
),
getReviewerEval: () => request<ReviewerEvalStatus>("/admin/evals/reviewer"),
logout: () => request<void>("/auth/logout", { method: "POST" }),
}
export function loginUrl(redirectTo?: string): string {
const target =
redirectTo ??
(typeof window !== "undefined"
? `${window.location.pathname}${window.location.search}${window.location.hash}`
: "")
const qs = target ? `?redirect_to=${encodeURIComponent(target)}` : ""
return `${API_BASE}/dashboard/api/auth/login${qs}`
}
export function slackConnectUrl(): string {
return `${API_BASE}/dashboard/api/slack/login`
}
export function notionConnectUrl(): string {
return `${API_BASE}/dashboard/api/notion/login`
}