mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-03 20:13:21 +00:00
feat: Require API keys from non langchain users (#448)
* feat: Require API keys from non langchain users * cr * cr
This commit is contained in:
parent
9e5f042c0e
commit
dda277456a
11 changed files with 361 additions and 83 deletions
|
|
@ -20,6 +20,7 @@ import {
|
||||||
} from "../../utils/github/label.js";
|
} from "../../utils/github/label.js";
|
||||||
import { ManagerGraphUpdate } from "@open-swe/shared/open-swe/manager/types";
|
import { ManagerGraphUpdate } from "@open-swe/shared/open-swe/manager/types";
|
||||||
import { RequestSource } from "../../constants.js";
|
import { RequestSource } from "../../constants.js";
|
||||||
|
import { isAllowedUser } from "../../utils/github/allowed-users.js";
|
||||||
|
|
||||||
const logger = createLogger(LogLevel.INFO, "GitHubIssueWebhook");
|
const logger = createLogger(LogLevel.INFO, "GitHubIssueWebhook");
|
||||||
|
|
||||||
|
|
@ -129,6 +130,13 @@ webhooks.on("issues.labeled", async ({ payload }) => {
|
||||||
userLogin: payload.sender.login,
|
userLogin: payload.sender.login,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!isAllowedUser(issueData.userLogin)) {
|
||||||
|
logger.error("User is not a member of allowed orgs", {
|
||||||
|
username: issueData.userLogin,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const langGraphClient = createLangGraphClient({
|
const langGraphClient = createLangGraphClient({
|
||||||
defaultHeaders: {
|
defaultHeaders: {
|
||||||
[GITHUB_INSTALLATION_TOKEN_COOKIE]: encryptSecret(
|
[GITHUB_INSTALLATION_TOKEN_COOKIE]: encryptSecret(
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
verifyGithubUserId,
|
verifyGithubUserId,
|
||||||
} from "@open-swe/shared/github/verify-user";
|
} from "@open-swe/shared/github/verify-user";
|
||||||
import {
|
import {
|
||||||
|
API_KEY_REQUIRED_MESSAGE,
|
||||||
GITHUB_INSTALLATION_NAME,
|
GITHUB_INSTALLATION_NAME,
|
||||||
GITHUB_INSTALLATION_TOKEN_COOKIE,
|
GITHUB_INSTALLATION_TOKEN_COOKIE,
|
||||||
GITHUB_TOKEN_COOKIE,
|
GITHUB_TOKEN_COOKIE,
|
||||||
|
|
@ -16,6 +17,8 @@ import { verifyGitHubWebhookOrThrow } from "./github.js";
|
||||||
import { createWithOwnerMetadata, createOwnerFilter } from "./utils.js";
|
import { createWithOwnerMetadata, createOwnerFilter } from "./utils.js";
|
||||||
import { LANGGRAPH_USER_PERMISSIONS } from "../constants.js";
|
import { LANGGRAPH_USER_PERMISSIONS } from "../constants.js";
|
||||||
import { getGitHubPatFromRequest } from "../utils/github-pat.js";
|
import { getGitHubPatFromRequest } from "../utils/github-pat.js";
|
||||||
|
import { isAllowedUser } from "../utils/github/allowed-users.js";
|
||||||
|
import { validate } from "uuid";
|
||||||
|
|
||||||
// TODO: Export from LangGraph SDK
|
// TODO: Export from LangGraph SDK
|
||||||
export interface BaseAuthReturn {
|
export interface BaseAuthReturn {
|
||||||
|
|
@ -31,6 +34,58 @@ interface AuthenticateReturn extends BaseAuthReturn {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function apiKeysInRequestBody(
|
||||||
|
bodyStr: string | Record<string, unknown>,
|
||||||
|
): boolean {
|
||||||
|
try {
|
||||||
|
const body = typeof bodyStr === "string" ? JSON.parse(bodyStr) : bodyStr;
|
||||||
|
if (
|
||||||
|
body.config?.configurable &&
|
||||||
|
("anthropicApiKey" in body.config.configurable.apiKeys ||
|
||||||
|
"openaiApiKey" in body.config.configurable.apiKeys ||
|
||||||
|
"googleApiKey" in body.config.configurable.apiKeys)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRunReq(reqUrl: string): boolean {
|
||||||
|
try {
|
||||||
|
const url = new URL(reqUrl);
|
||||||
|
const pathnameParts = url.pathname.split("/");
|
||||||
|
const isCreateAndWait = !!(
|
||||||
|
pathnameParts[1] === "threads" &&
|
||||||
|
validate(pathnameParts[2]) &&
|
||||||
|
pathnameParts[3] === "runs" &&
|
||||||
|
pathnameParts[4] === "wait" &&
|
||||||
|
pathnameParts.length === 5
|
||||||
|
);
|
||||||
|
const isCreateBackground = !!(
|
||||||
|
pathnameParts[1] === "threads" &&
|
||||||
|
validate(pathnameParts[2]) &&
|
||||||
|
pathnameParts[3] === "runs" &&
|
||||||
|
pathnameParts.length === 4
|
||||||
|
);
|
||||||
|
const isCreateStream = !!(
|
||||||
|
pathnameParts[1] === "threads" &&
|
||||||
|
validate(pathnameParts[2]) &&
|
||||||
|
pathnameParts[3] === "runs" &&
|
||||||
|
pathnameParts[4] === "stream" &&
|
||||||
|
pathnameParts.length === 5
|
||||||
|
);
|
||||||
|
|
||||||
|
return !!isCreateAndWait || !!isCreateBackground || !!isCreateStream;
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const auth = new Auth()
|
export const auth = new Auth()
|
||||||
.authenticate<AuthenticateReturn>(async (request: Request) => {
|
.authenticate<AuthenticateReturn>(async (request: Request) => {
|
||||||
if (request.method === "OPTIONS") {
|
if (request.method === "OPTIONS") {
|
||||||
|
|
@ -100,10 +155,18 @@ export const auth = new Auth()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const encryptedAccessToken = request.headers.get(GITHUB_TOKEN_COOKIE);
|
||||||
|
const decryptedAccessToken = encryptedAccessToken
|
||||||
|
? decryptSecret(encryptedAccessToken, encryptionKey)
|
||||||
|
: undefined;
|
||||||
|
const decryptedInstallationToken = decryptSecret(
|
||||||
|
encryptedInstallationToken,
|
||||||
|
encryptionKey,
|
||||||
|
);
|
||||||
|
|
||||||
let user: GithubUser | undefined;
|
let user: GithubUser | undefined;
|
||||||
|
|
||||||
const encryptedAccessToken = request.headers.get(GITHUB_TOKEN_COOKIE);
|
if (!decryptedAccessToken) {
|
||||||
if (!encryptedAccessToken) {
|
|
||||||
// If there isn't a user access token, check to see if the user info is in headers.
|
// If there isn't a user access token, check to see if the user info is in headers.
|
||||||
// This would indicate a bot created the request.
|
// This would indicate a bot created the request.
|
||||||
const userIdHeader = request.headers.get(GITHUB_USER_ID_HEADER);
|
const userIdHeader = request.headers.get(GITHUB_USER_ID_HEADER);
|
||||||
|
|
@ -114,15 +177,13 @@ export const auth = new Auth()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
user = await verifyGithubUserId(
|
user = await verifyGithubUserId(
|
||||||
decryptSecret(encryptedInstallationToken, encryptionKey),
|
decryptedInstallationToken,
|
||||||
Number(userIdHeader),
|
Number(userIdHeader),
|
||||||
userLoginHeader,
|
userLoginHeader,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Ensure we decrypt the token before passing to the verification function.
|
// Ensure we decrypt the token before passing to the verification function.
|
||||||
user = await verifyGithubUser(
|
user = await verifyGithubUser(decryptedAccessToken);
|
||||||
decryptSecret(encryptedAccessToken, encryptionKey),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
|
@ -131,6 +192,18 @@ export const auth = new Auth()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reqCopy = request.clone();
|
||||||
|
const reqBody = await reqCopy.text();
|
||||||
|
if (!isAllowedUser(user.login)) {
|
||||||
|
if (isRunReq(request.url)) {
|
||||||
|
if (!apiKeysInRequestBody(reqBody)) {
|
||||||
|
throw new HTTPException(401, {
|
||||||
|
message: API_KEY_REQUIRED_MESSAGE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
identity: user.id.toString(),
|
identity: user.id.toString(),
|
||||||
is_authenticated: true,
|
is_authenticated: true,
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,10 @@ export function getCustomConfigurableFields(
|
||||||
GraphConfigurationMetadata,
|
GraphConfigurationMetadata,
|
||||||
)) {
|
)) {
|
||||||
if (key in config.configurable) {
|
if (key in config.configurable) {
|
||||||
if (metadataValue.x_open_swe_ui_config.type !== "hidden") {
|
if (
|
||||||
|
metadataValue.x_open_swe_ui_config.type !== "hidden" ||
|
||||||
|
key === "apiKeys"
|
||||||
|
) {
|
||||||
result[key as keyof GraphConfig["configurable"]] =
|
result[key as keyof GraphConfig["configurable"]] =
|
||||||
config.configurable[key as keyof GraphConfig["configurable"]];
|
config.configurable[key as keyof GraphConfig["configurable"]];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
66
apps/open-swe/src/utils/github/allowed-users.ts
Normal file
66
apps/open-swe/src/utils/github/allowed-users.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
export const ALLOWED_USERS = [
|
||||||
|
"agola11",
|
||||||
|
"akira",
|
||||||
|
"aliyanishfaq",
|
||||||
|
"andrewnguonly",
|
||||||
|
"angus-langchain",
|
||||||
|
"bracesproul",
|
||||||
|
"ArthurLangChain",
|
||||||
|
"baskaryan",
|
||||||
|
"bvs-langchain",
|
||||||
|
"catherine-langchain",
|
||||||
|
"ccurme",
|
||||||
|
"crystalro0",
|
||||||
|
"dqbd",
|
||||||
|
"emily-langchain",
|
||||||
|
"eric-langchain",
|
||||||
|
"EugeneJinXin",
|
||||||
|
"eyurtsev",
|
||||||
|
"gladwig2",
|
||||||
|
"hari-dhanushkodi",
|
||||||
|
"hinthornw",
|
||||||
|
"hntrl",
|
||||||
|
"hwchase17",
|
||||||
|
"iakshay",
|
||||||
|
"isahers1",
|
||||||
|
"j-broekhuizen",
|
||||||
|
"jacoblee93",
|
||||||
|
"jdrogers940",
|
||||||
|
"joaquin-borggio-lc",
|
||||||
|
"katmayb",
|
||||||
|
"keshivtandon",
|
||||||
|
"langchain-infra",
|
||||||
|
"lc-arjun",
|
||||||
|
"lc-chad",
|
||||||
|
"lnhsingh",
|
||||||
|
"madams0013",
|
||||||
|
"mdrxy",
|
||||||
|
"mhk197",
|
||||||
|
"nfcampos",
|
||||||
|
"nhuang-lc",
|
||||||
|
"nitboss",
|
||||||
|
"PeriniM",
|
||||||
|
"phvash",
|
||||||
|
"QuentinBrosse",
|
||||||
|
"rlancemartin",
|
||||||
|
"romain-priour-lc",
|
||||||
|
"samecrowder",
|
||||||
|
"samnoyes",
|
||||||
|
"starmorph",
|
||||||
|
"suraj-langchain",
|
||||||
|
"sydney-runkle",
|
||||||
|
"tanushree-sharma",
|
||||||
|
"victorm-lc",
|
||||||
|
"xornivore",
|
||||||
|
"xuro-langchain",
|
||||||
|
];
|
||||||
|
|
||||||
|
// HACK: Until we setup proper support for API credits, we will only allow users to self host Open SWE
|
||||||
|
export function isAllowedUser(username: string): boolean {
|
||||||
|
const restrictToLangChainAuth =
|
||||||
|
process.env.RESTRICT_TO_LANGCHAIN_AUTH === "true";
|
||||||
|
if (!restrictToLangChainAuth) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return ALLOWED_USERS.some((u) => u === username);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { initChatModel } from "langchain/chat_models/universal";
|
import { initChatModel } from "langchain/chat_models/universal";
|
||||||
import { GraphConfig } from "@open-swe/shared/open-swe/types";
|
import { GraphConfig } from "@open-swe/shared/open-swe/types";
|
||||||
|
import { isAllowedUser } from "./github/allowed-users.js";
|
||||||
|
import { decryptSecret } from "@open-swe/shared/crypto";
|
||||||
|
|
||||||
export enum Task {
|
export enum Task {
|
||||||
/**
|
/**
|
||||||
|
|
@ -35,6 +37,22 @@ const TASK_TO_CONFIG_DEFAULTS_MAP = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const providerToApiKey = (
|
||||||
|
providerName: string,
|
||||||
|
apiKeys: Record<string, string>,
|
||||||
|
): string => {
|
||||||
|
switch (providerName) {
|
||||||
|
case "openai":
|
||||||
|
return apiKeys.openaiApiKey;
|
||||||
|
case "anthropic":
|
||||||
|
return apiKeys.anthropicApiKey;
|
||||||
|
case "google-genai":
|
||||||
|
return apiKeys.googleApiKey;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown provider: ${providerName}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export async function loadModel(config: GraphConfig, task: Task) {
|
export async function loadModel(config: GraphConfig, task: Task) {
|
||||||
const modelStr =
|
const modelStr =
|
||||||
config.configurable?.[`${task}ModelName`] ??
|
config.configurable?.[`${task}ModelName`] ??
|
||||||
|
|
@ -66,9 +84,35 @@ export async function loadModel(config: GraphConfig, task: Task) {
|
||||||
maxTokens = maxTokens > 8_192 ? 8_192 : maxTokens;
|
maxTokens = maxTokens > 8_192 ? 8_192 : maxTokens;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Fix types
|
||||||
|
const userLogin = (config.configurable as any)?.langgraph_auth_user
|
||||||
|
?.display_name;
|
||||||
|
const secretsEncryptionKey = process.env.SECRETS_ENCRYPTION_KEY;
|
||||||
|
if (!secretsEncryptionKey) {
|
||||||
|
throw new Error("SECRETS_ENCRYPTION_KEY environment variable is required");
|
||||||
|
}
|
||||||
|
if (!userLogin) {
|
||||||
|
throw new Error("User login not found in config");
|
||||||
|
}
|
||||||
|
const apiKeys = config.configurable?.apiKeys;
|
||||||
|
let apiKey: string | null = null;
|
||||||
|
if (!isAllowedUser(userLogin)) {
|
||||||
|
if (!apiKeys) {
|
||||||
|
throw new Error("API keys not found in config");
|
||||||
|
}
|
||||||
|
apiKey = decryptSecret(
|
||||||
|
providerToApiKey(modelProvider, apiKeys),
|
||||||
|
secretsEncryptionKey,
|
||||||
|
);
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error("No API key found for provider: " + modelProvider);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const model = await initChatModel(modelName, {
|
const model = await initChatModel(modelName, {
|
||||||
modelProvider,
|
modelProvider,
|
||||||
temperature: thinkingModel ? undefined : temperature,
|
temperature: thinkingModel ? undefined : temperature,
|
||||||
|
...(apiKey ? { apiKey } : {}),
|
||||||
...(thinkingModel && modelProvider === "anthropic"
|
...(thinkingModel && modelProvider === "anthropic"
|
||||||
? {
|
? {
|
||||||
thinking: { budget_tokens: thinkingBudgetTokens, type: "enabled" },
|
thinking: { budget_tokens: thinkingBudgetTokens, type: "enabled" },
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
getInstallationNameFromReq,
|
getInstallationNameFromReq,
|
||||||
getGitHubAccessTokenOrThrow,
|
getGitHubAccessTokenOrThrow,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
|
import { encryptSecret } from "@open-swe/shared/crypto";
|
||||||
|
|
||||||
// This file acts as a proxy for requests to your LangGraph server.
|
// This file acts as a proxy for requests to your LangGraph server.
|
||||||
// Read the [Going to Production](https://github.com/langchain-ai/agent-chat-ui?tab=readme-ov-file#going-to-production) section for more information.
|
// Read the [Going to Production](https://github.com/langchain-ai/agent-chat-ui?tab=readme-ov-file#going-to-production) section for more information.
|
||||||
|
|
@ -19,6 +20,33 @@ export const { GET, POST, PUT, PATCH, DELETE, OPTIONS, runtime } =
|
||||||
apiUrl: process.env.LANGGRAPH_API_URL ?? "http://localhost:2024",
|
apiUrl: process.env.LANGGRAPH_API_URL ?? "http://localhost:2024",
|
||||||
runtime: "edge", // default
|
runtime: "edge", // default
|
||||||
disableWarningLog: true,
|
disableWarningLog: true,
|
||||||
|
bodyParameters: (req, body) => {
|
||||||
|
if (body.config?.configurable && "apiKeys" in body.config.configurable) {
|
||||||
|
const encryptionKey = process.env.SECRETS_ENCRYPTION_KEY;
|
||||||
|
if (!encryptionKey) {
|
||||||
|
throw new Error(
|
||||||
|
"SECRETS_ENCRYPTION_KEY environment variable is required",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKeys = body.config.configurable.apiKeys;
|
||||||
|
const encryptedApiKeys: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
// Encrypt each field in the apiKeys object
|
||||||
|
for (const [key, value] of Object.entries(apiKeys)) {
|
||||||
|
if (typeof value === "string" && value.trim() !== "") {
|
||||||
|
encryptedApiKeys[key] = encryptSecret(value, encryptionKey);
|
||||||
|
} else {
|
||||||
|
encryptedApiKeys[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the body with encrypted apiKeys
|
||||||
|
body.config.configurable.apiKeys = encryptedApiKeys;
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
},
|
||||||
headers: async (req) => {
|
headers: async (req) => {
|
||||||
const encryptionKey = process.env.SECRETS_ENCRYPTION_KEY;
|
const encryptionKey = process.env.SECRETS_ENCRYPTION_KEY;
|
||||||
if (!encryptionKey) {
|
if (!encryptionKey) {
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,20 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
toastOptions={{
|
toastOptions={{
|
||||||
classNames: {
|
classNames: {
|
||||||
toast:
|
toast:
|
||||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg dark:group-[.toaster]:bg-slate-950 dark:group-[.toaster]:border-slate-800 dark:group-[.toaster]:shadow-2xl dark:group-[.toaster]:shadow-black/20",
|
||||||
description: "group-[.toast]:text-muted-foreground",
|
description:
|
||||||
|
"group-[.toast]:text-muted-foreground dark:group-[.toast]:text-slate-400",
|
||||||
actionButton:
|
actionButton:
|
||||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground font-medium",
|
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground font-medium hover:group-[.toast]:bg-primary/90 dark:group-[.toast]:bg-slate-200 dark:group-[.toast]:text-slate-900 dark:hover:group-[.toast]:bg-slate-100",
|
||||||
cancelButton:
|
cancelButton:
|
||||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground font-medium",
|
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground font-medium hover:group-[.toast]:bg-muted/80 dark:group-[.toast]:bg-slate-800 dark:group-[.toast]:text-slate-300 dark:hover:group-[.toast]:bg-slate-700",
|
||||||
|
success:
|
||||||
|
"group-[.toast]:bg-green-50 group-[.toast]:text-green-900 group-[.toast]:border-green-200 dark:group-[.toast]:bg-green-950/50 dark:group-[.toast]:text-green-100 dark:group-[.toast]:border-green-800/50",
|
||||||
|
error:
|
||||||
|
"group-[.toast]:bg-red-50 group-[.toast]:text-red-900 group-[.toast]:border-red-200 dark:group-[.toast]:bg-red-950/50 dark:group-[.toast]:text-red-100 dark:group-[.toast]:border-red-800/50",
|
||||||
|
warning:
|
||||||
|
"group-[.toast]:bg-yellow-50 group-[.toast]:text-yellow-900 group-[.toast]:border-yellow-200 dark:group-[.toast]:bg-yellow-950/50 dark:group-[.toast]:text-yellow-100 dark:group-[.toast]:border-yellow-800/50",
|
||||||
|
info: "group-[.toast]:bg-blue-50 group-[.toast]:text-blue-900 group-[.toast]:border-blue-200 dark:group-[.toast]:bg-blue-950/50 dark:group-[.toast]:text-blue-100 dark:group-[.toast]:border-blue-800/50",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,10 @@ import { GraphState } from "@open-swe/shared/open-swe/types";
|
||||||
import { Base64ContentBlock, HumanMessage } from "@langchain/core/messages";
|
import { Base64ContentBlock, HumanMessage } from "@langchain/core/messages";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { DEFAULT_CONFIG_KEY, useConfigStore } from "@/hooks/useConfigStore";
|
import { DEFAULT_CONFIG_KEY, useConfigStore } from "@/hooks/useConfigStore";
|
||||||
import { MANAGER_GRAPH_ID } from "@open-swe/shared/constants";
|
import {
|
||||||
|
API_KEY_REQUIRED_MESSAGE,
|
||||||
|
MANAGER_GRAPH_ID,
|
||||||
|
} from "@open-swe/shared/constants";
|
||||||
import { ManagerGraphUpdate } from "@open-swe/shared/open-swe/manager/types";
|
import { ManagerGraphUpdate } from "@open-swe/shared/open-swe/manager/types";
|
||||||
import { useDraftStorage } from "@/hooks/useDraftStorage";
|
import { useDraftStorage } from "@/hooks/useDraftStorage";
|
||||||
|
|
||||||
|
|
@ -113,7 +116,31 @@ export function TerminalInput({
|
||||||
setContentBlocks([]);
|
setContentBlocks([]);
|
||||||
setAutoAcceptPlan(false);
|
setAutoAcceptPlan(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
if (
|
||||||
|
typeof e === "object" &&
|
||||||
|
e !== null &&
|
||||||
|
"message" in e &&
|
||||||
|
e.message !== null &&
|
||||||
|
typeof e.message === "string" &&
|
||||||
|
e.message.includes(API_KEY_REQUIRED_MESSAGE)
|
||||||
|
) {
|
||||||
|
toast.error(
|
||||||
|
<p>
|
||||||
|
{API_KEY_REQUIRED_MESSAGE} Please add your API key(s) in{" "}
|
||||||
|
<a
|
||||||
|
className="text-blue-500 underline underline-offset-1 dark:text-blue-400"
|
||||||
|
href="/settings?tab=api-keys"
|
||||||
|
>
|
||||||
|
settings
|
||||||
|
</a>
|
||||||
|
</p>,
|
||||||
|
{
|
||||||
|
richColors: true,
|
||||||
|
duration: 30_000,
|
||||||
|
closeButton: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,12 @@ import { Eye, EyeOff, Key, Trash2 } from "lucide-react";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useConfigStore, DEFAULT_CONFIG_KEY } from "@/hooks/useConfigStore";
|
||||||
|
|
||||||
interface ApiKey {
|
interface ApiKey {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
description?: string;
|
||||||
value: string;
|
value: string;
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
lastUsed?: string;
|
lastUsed?: string;
|
||||||
|
|
@ -26,72 +28,82 @@ interface ApiKeySection {
|
||||||
keys: ApiKey[];
|
keys: ApiKey[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const API_KEY_SECTIONS: Record<string, Omit<ApiKeySection, "keys">> = {
|
||||||
|
llms: {
|
||||||
|
title: "LLMs",
|
||||||
|
},
|
||||||
|
infrastructure: {
|
||||||
|
title: "Infrastructure",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const API_KEY_DEFINITIONS = {
|
||||||
|
llms: [
|
||||||
|
{ id: "anthropicApiKey", name: "Anthropic" },
|
||||||
|
{ id: "openaiApiKey", name: "OpenAI" },
|
||||||
|
{ id: "googleApiKey", name: "Google Gen AI" },
|
||||||
|
],
|
||||||
|
infrastructure: [
|
||||||
|
{
|
||||||
|
id: "daytonaApiKey",
|
||||||
|
name: "Daytona",
|
||||||
|
description: "Users not required to set this if using the demo",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
export function APIKeysTab() {
|
export function APIKeysTab() {
|
||||||
const [apiKeySections, setApiKeySections] = useState<
|
const { getConfig, updateConfig } = useConfigStore();
|
||||||
Record<string, ApiKeySection>
|
const config = getConfig(DEFAULT_CONFIG_KEY);
|
||||||
>({
|
|
||||||
llms: {
|
|
||||||
title: "LLMs",
|
|
||||||
keys: [
|
|
||||||
{
|
|
||||||
id: "anthropicApiKey",
|
|
||||||
name: "Anthropic",
|
|
||||||
value: "",
|
|
||||||
isVisible: false,
|
|
||||||
},
|
|
||||||
{ id: "openaiApiKey", name: "OpenAI", value: "", isVisible: false },
|
|
||||||
{
|
|
||||||
id: "googleApiKey",
|
|
||||||
name: "Google Gen AI",
|
|
||||||
value: "",
|
|
||||||
isVisible: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
infrastructure: {
|
|
||||||
title: "Infrastructure",
|
|
||||||
keys: [
|
|
||||||
{ id: "daytonaApiKey", name: "Daytona", value: "", isVisible: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const toggleKeyVisibility = (sectionKey: string, keyId: string) => {
|
const [visibilityState, setVisibilityState] = useState<
|
||||||
setApiKeySections((prev) => ({
|
Record<string, boolean>
|
||||||
|
>({});
|
||||||
|
|
||||||
|
const toggleKeyVisibility = (keyId: string) => {
|
||||||
|
setVisibilityState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[sectionKey]: {
|
[keyId]: !prev[keyId],
|
||||||
...prev[sectionKey],
|
|
||||||
keys: prev[sectionKey].keys.map((key) =>
|
|
||||||
key.id === keyId ? { ...key, isVisible: !key.isVisible } : key,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateApiKey = (sectionKey: string, keyId: string, value: string) => {
|
const updateApiKey = (keyId: string, value: string) => {
|
||||||
setApiKeySections((prev) => ({
|
const currentApiKeys = config.apiKeys || {};
|
||||||
...prev,
|
updateConfig(DEFAULT_CONFIG_KEY, "apiKeys", {
|
||||||
[sectionKey]: {
|
...currentApiKeys,
|
||||||
...prev[sectionKey],
|
[keyId]: value,
|
||||||
keys: prev[sectionKey].keys.map((key) =>
|
});
|
||||||
key.id === keyId ? { ...key, value } : key,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteApiKey = (sectionKey: string, keyId: string) => {
|
const deleteApiKey = (keyId: string) => {
|
||||||
setApiKeySections((prev) => ({
|
const currentApiKeys = config.apiKeys || {};
|
||||||
...prev,
|
const updatedApiKeys = { ...currentApiKeys };
|
||||||
[sectionKey]: {
|
delete updatedApiKeys[keyId];
|
||||||
...prev[sectionKey],
|
updateConfig(DEFAULT_CONFIG_KEY, "apiKeys", updatedApiKeys);
|
||||||
keys: prev[sectionKey].keys.map((key) =>
|
|
||||||
key.id === keyId ? { ...key, value: "" } : key,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getApiKeySections = (): Record<string, ApiKeySection> => {
|
||||||
|
const sections: Record<string, ApiKeySection> = {};
|
||||||
|
const apiKeys = config.apiKeys || {};
|
||||||
|
|
||||||
|
Object.entries(API_KEY_SECTIONS).forEach(([sectionKey, sectionInfo]) => {
|
||||||
|
sections[sectionKey] = {
|
||||||
|
...sectionInfo,
|
||||||
|
keys: API_KEY_DEFINITIONS[
|
||||||
|
sectionKey as keyof typeof API_KEY_DEFINITIONS
|
||||||
|
].map((keyDef) => ({
|
||||||
|
...keyDef,
|
||||||
|
value: apiKeys[keyDef.id] || "",
|
||||||
|
isVisible: visibilityState[keyDef.id] || false,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return sections;
|
||||||
|
};
|
||||||
|
|
||||||
|
const apiKeySections = getApiKeySections();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{Object.entries(apiKeySections).map(([sectionKey, section]) => (
|
{Object.entries(apiKeySections).map(([sectionKey, section]) => (
|
||||||
|
|
@ -148,13 +160,18 @@ export function APIKeysTab() {
|
||||||
>
|
>
|
||||||
API Key
|
API Key
|
||||||
</Label>
|
</Label>
|
||||||
|
{apiKey.description && (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{apiKey.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="mt-1 flex items-center gap-2">
|
<div className="mt-1 flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
id={`${apiKey.id}-key`}
|
id={`${apiKey.id}-key`}
|
||||||
type={apiKey.isVisible ? "text" : "password"}
|
type={apiKey.isVisible ? "text" : "password"}
|
||||||
value={apiKey.value}
|
value={apiKey.value}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateApiKey(sectionKey, apiKey.id, e.target.value)
|
updateApiKey(apiKey.id, e.target.value)
|
||||||
}
|
}
|
||||||
placeholder={`Enter your ${apiKey.name} API key`}
|
placeholder={`Enter your ${apiKey.name} API key`}
|
||||||
className="font-mono text-sm"
|
className="font-mono text-sm"
|
||||||
|
|
@ -162,9 +179,7 @@ export function APIKeysTab() {
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() => toggleKeyVisibility(apiKey.id)}
|
||||||
toggleKeyVisibility(sectionKey, apiKey.id)
|
|
||||||
}
|
|
||||||
className="px-2"
|
className="px-2"
|
||||||
>
|
>
|
||||||
{apiKey.isVisible ? (
|
{apiKey.isVisible ? (
|
||||||
|
|
@ -177,7 +192,7 @@ export function APIKeysTab() {
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => deleteApiKey(sectionKey, apiKey.id)}
|
onClick={() => deleteApiKey(apiKey.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-2",
|
"px-2",
|
||||||
"text-destructive hover:bg-destructive/10 hover:text-destructive",
|
"text-destructive hover:bg-destructive/10 hover:text-destructive",
|
||||||
|
|
@ -192,16 +207,8 @@ export function APIKeysTab() {
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
Your API key is stored securely and encrypted
|
Your API key is stored
|
||||||
</p>
|
</p>
|
||||||
{apiKey.value && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
Test Connection
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -38,3 +38,6 @@ export const DEFAULT_MCP_SERVERS = {
|
||||||
stderr: "inherit" as const,
|
stderr: "inherit" as const,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const API_KEY_REQUIRED_MESSAGE =
|
||||||
|
"Unknown users must provide API keys to use the Open SWE demo application";
|
||||||
|
|
|
||||||
|
|
@ -372,6 +372,11 @@ export const GraphConfigurationMetadata: {
|
||||||
"JSON configuration for custom MCP servers. LangGraph docs server is set by default. See the `mcpServers` field of the LangChain MCP Adapters `ClientConfig` type for information on this schema. [Documentation here](https://v03.api.js.langchain.com/types/_langchain_mcp_adapters.ClientConfig.html).",
|
"JSON configuration for custom MCP servers. LangGraph docs server is set by default. See the `mcpServers` field of the LangChain MCP Adapters `ClientConfig` type for information on this schema. [Documentation here](https://v03.api.js.langchain.com/types/_langchain_mcp_adapters.ClientConfig.html).",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
apiKeys: {
|
||||||
|
x_open_swe_ui_config: {
|
||||||
|
type: "hidden",
|
||||||
|
},
|
||||||
|
},
|
||||||
[GITHUB_TOKEN_COOKIE]: {
|
[GITHUB_TOKEN_COOKIE]: {
|
||||||
x_open_swe_ui_config: {
|
x_open_swe_ui_config: {
|
||||||
type: "hidden",
|
type: "hidden",
|
||||||
|
|
@ -496,6 +501,12 @@ export const GraphConfiguration = z.object({
|
||||||
maxTokens: withLangGraph(z.number().optional(), {
|
maxTokens: withLangGraph(z.number().optional(), {
|
||||||
metadata: GraphConfigurationMetadata.maxTokens,
|
metadata: GraphConfigurationMetadata.maxTokens,
|
||||||
}),
|
}),
|
||||||
|
/**
|
||||||
|
* User defined API keys to use
|
||||||
|
*/
|
||||||
|
apiKeys: withLangGraph(z.record(z.string(), z.string()).optional(), {
|
||||||
|
metadata: GraphConfigurationMetadata.apiKeys,
|
||||||
|
}),
|
||||||
/**
|
/**
|
||||||
* The user's GitHub access token. To be used in requests to get information about the user.
|
* The user's GitHub access token. To be used in requests to get information about the user.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue