init
Browse files- app.py +205 -0
- gradio_webhook_payload.py +131 -0
- gradio_webhook_server.py +505 -0
- linear_api_utils.py +98 -0
- requirements.txt +3 -0
- sleep_per_last_token_model.py +56 -0
app.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
このコードは以下のコードをLinear.appに対応させたものです。公式のコードではありません。
|
| 3 |
+
https://github.com/huggingface/huggingface_hub/blob/main/src/huggingface_hub/_webhooks_server.py
|
| 4 |
+
https://github.com/huggingface/huggingface_hub/blob/main/src/huggingface_hub/_webhooks_payload.py
|
| 5 |
+
|
| 6 |
+
まだ、Issue Objectのごく一部しか対応してません。
|
| 7 |
+
https://studio.apollographql.com/public/Linear-API/variant/current/schema/reference/objects/Issue
|
| 8 |
+
|
| 9 |
+
.envファイルに、api_key = linear-api-key および、webhook_secret = linear-webhook-secretの設定が必要です。
|
| 10 |
+
|
| 11 |
+
ローカルは起動ごとにURLが変わるので、起動時にLinear-APIでURLを更新しています。
|
| 12 |
+
target_webhook_label(デフォルト値はGradio)で指定したラベルのwebhookを実行時に上書きます。
|
| 13 |
+
|
| 14 |
+
gradio,fastapi,pydanticをあらかじめインストールしておく必要があります。
|
| 15 |
+
.envにlinear api_keyおよび、linear-webhook secretを記述する必要があります。
|
| 16 |
+
また、このExampleはUpdateのみなので、最初にラベルGradioでWebhookを作っておいてください。
|
| 17 |
+
|
| 18 |
+
** Linear.app 対応部分の著作権表示 **
|
| 19 |
+
# Copyright 2025-present, Akihito Miyazaki
|
| 20 |
+
#
|
| 21 |
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 22 |
+
# you may not use this file except in compliance with the License.
|
| 23 |
+
# You may obtain a copy of the License at
|
| 24 |
+
#
|
| 25 |
+
# http://www.apache.org/licenses/LICENSE-2.0
|
| 26 |
+
#
|
| 27 |
+
# Unless required by applicable law or agreed to in writing, software
|
| 28 |
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 29 |
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 30 |
+
# See the License for the specific language governing permissions and
|
| 31 |
+
# limitations under the License.
|
| 32 |
+
|
| 33 |
+
** Hugging Face Hub ライブラリのライセンス表示 **
|
| 34 |
+
# Copyright 2023-present, the HuggingFace Inc. team.
|
| 35 |
+
#
|
| 36 |
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 37 |
+
# you may not use this file except in compliance with the License.
|
| 38 |
+
# You may obtain a copy of the License at
|
| 39 |
+
#
|
| 40 |
+
# http://www.apache.org/licenses/LICENSE-2.0
|
| 41 |
+
#
|
| 42 |
+
# Unless required by applicable law or agreed to in writing, software
|
| 43 |
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 44 |
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 45 |
+
# See the License for the specific language governing permissions and
|
| 46 |
+
# limitations under the License.
|
| 47 |
+
|
| 48 |
+
このコードには Hugging Face Hub ライブラリの一部が含まれており、Apache License, Version 2.0 の下でライセンスされています。
|
| 49 |
+
ライセンスの全文は以下から確認できます: http://www.apache.org/licenses/LICENSE-2.0
|
| 50 |
+
"""
|
| 51 |
+
|
| 52 |
+
import os
|
| 53 |
+
from pprint import pprint
|
| 54 |
+
|
| 55 |
+
import gradio as gr
|
| 56 |
+
from smolagents import CodeAgent, HfApiModel
|
| 57 |
+
|
| 58 |
+
from linear_api_utils import execute_query
|
| 59 |
+
from gradio_webhook_server import WebhooksServer
|
| 60 |
+
from gradio_webhook_payload import WebhookPayload
|
| 61 |
+
from sleep_per_last_token_model import SleepPerLastTokenModelLiteLLM
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def getenv(key, is_value_error_on_null=True):
|
| 65 |
+
value = os.getenv(key)
|
| 66 |
+
if value is None:
|
| 67 |
+
from dotenv import load_dotenv
|
| 68 |
+
|
| 69 |
+
load_dotenv()
|
| 70 |
+
value = os.getenv(key)
|
| 71 |
+
if is_value_error_on_null and value is None:
|
| 72 |
+
raise ValueError(f"Need {key} on secret or .env(If running on local)")
|
| 73 |
+
return value
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# SETTINGS
|
| 77 |
+
LINEAR_ISSUE_LABEL = "huggingface-public" # only show issue with this label,I added for demo you can remove this
|
| 78 |
+
LINEAR_WEBHOOK_LABEL = "Huggingface" # you have to create in linear before run script
|
| 79 |
+
# set secret key on Space setting or .env(local)
|
| 80 |
+
# hf_token = getenv("HF_TOKEN")
|
| 81 |
+
groq_api_key = getenv("GROQ_API_KEY")
|
| 82 |
+
api_key = getenv("LINEAR_API_KEY")
|
| 83 |
+
webhook_key = getenv("LINEAR_WEBHOOK_KEY")
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
if api_key is None:
|
| 87 |
+
raise ValueError("Need LINEAR_API_KEY on secret")
|
| 88 |
+
if webhook_key is None:
|
| 89 |
+
raise ValueError("Need LINEAR_WEBHOOK_KEY on secret")
|
| 90 |
+
|
| 91 |
+
webhook_query_text = """
|
| 92 |
+
query {
|
| 93 |
+
webhooks{
|
| 94 |
+
nodes {
|
| 95 |
+
id
|
| 96 |
+
label
|
| 97 |
+
url
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
"""
|
| 102 |
+
target_webhook_label = LINEAR_WEBHOOK_LABEL # filter not working,set manual
|
| 103 |
+
target_webhook_id = None
|
| 104 |
+
result = execute_query("webhook", webhook_query_text, api_key)
|
| 105 |
+
for webhook in result["data"]["webhooks"]["nodes"]:
|
| 106 |
+
if target_webhook_label == webhook["label"]:
|
| 107 |
+
target_webhook_id = webhook["id"]
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
app = None
|
| 111 |
+
|
| 112 |
+
model = SleepPerLastTokenModelLiteLLM(
|
| 113 |
+
max_tokens=100,
|
| 114 |
+
temperature=0.5,
|
| 115 |
+
model_id="groq/llama3-8b-8192",
|
| 116 |
+
api_base="https://api.groq.com/openai/v1/",
|
| 117 |
+
api_key=groq_api_key,
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
"""
|
| 121 |
+
model = HfApiModel(
|
| 122 |
+
max_tokens=100,
|
| 123 |
+
temperature=0.5,
|
| 124 |
+
model_id="google/gemma-2-2b-it",
|
| 125 |
+
custom_role_conversions=None,
|
| 126 |
+
token=hf_token,
|
| 127 |
+
)
|
| 128 |
+
"""
|
| 129 |
+
|
| 130 |
+
agent = CodeAgent(
|
| 131 |
+
model=model,
|
| 132 |
+
tools=[], ## add your tools here (don't remove final answer)
|
| 133 |
+
max_steps=1,
|
| 134 |
+
verbosity_level=1,
|
| 135 |
+
grammar=None,
|
| 136 |
+
planning_interval=None,
|
| 137 |
+
name=None,
|
| 138 |
+
description=None,
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def update():
|
| 143 |
+
result = agent.run(f"how to solve this issue:{app.text}")
|
| 144 |
+
return app.text, result
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
with gr.Blocks() as ui:
|
| 148 |
+
gr.HTML("""<h1>Linear.app Webhook Server</h1>
|
| 149 |
+
<p>This is Demo of Direct Webhook-triggered AIAgen</p>
|
| 150 |
+
<p>it's still just simple code,sadly you have to click to show updates.</p>
|
| 151 |
+
<p><b>Imagine an agent, responding instantly.</b></p>
|
| 152 |
+
<p>Technically Gradio have no way to update without action<p>
|
| 153 |
+
<p></p><br>
|
| 154 |
+
<p>I'm confused by Hugging Face's new pricing system. I'm worried about potentially massive inference API bills, so I switched to Groq.</p>
|
| 155 |
+
<p>I believe my use of the Groq API is currently compliant with Hugging Face's Content Policy.</p>
|
| 156 |
+
<p>If you have any questions, please disable the Space or contact me before taking any action against my account. Thank you for your understanding.</p>
|
| 157 |
+
""")
|
| 158 |
+
with gr.Row():
|
| 159 |
+
issue_box = gr.Textbox(label="Issue")
|
| 160 |
+
output_box = gr.Textbox(label="出力")
|
| 161 |
+
bt = gr.Button("Ask AI")
|
| 162 |
+
bt.click(update, outputs=[issue_box, output_box])
|
| 163 |
+
|
| 164 |
+
app = WebhooksServer(
|
| 165 |
+
ui=ui,
|
| 166 |
+
webhook_secret=webhook_key, # loaded by load_api_key
|
| 167 |
+
)
|
| 168 |
+
app.text = "nothing"
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
@app.add_webhook("/linear_webhook")
|
| 172 |
+
async def updated(payload: WebhookPayload):
|
| 173 |
+
pprint(payload.dict(), indent=4)
|
| 174 |
+
|
| 175 |
+
data = payload.dict()["data"]
|
| 176 |
+
has_label = True
|
| 177 |
+
if LINEAR_ISSUE_LABEL:
|
| 178 |
+
has_label = False
|
| 179 |
+
for label in data["labels"]:
|
| 180 |
+
if label["name"] == LINEAR_ISSUE_LABEL:
|
| 181 |
+
has_label = True
|
| 182 |
+
|
| 183 |
+
if has_label:
|
| 184 |
+
text = data["description"]
|
| 185 |
+
app.text = text
|
| 186 |
+
return {"message": "ok"}
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def webhook_update(url):
|
| 190 |
+
webhook_update_text = """
|
| 191 |
+
mutation {
|
| 192 |
+
webhookUpdate(
|
| 193 |
+
id: "%s"
|
| 194 |
+
input:{
|
| 195 |
+
url:"%s"
|
| 196 |
+
}
|
| 197 |
+
) {
|
| 198 |
+
success
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
""" % (target_webhook_id, url)
|
| 202 |
+
result = execute_query("webhook_update", webhook_update_text, api_key)
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
app.launch(webhook_update=webhook_update)
|
gradio_webhook_payload.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
このコードは以下のコードをLinear.appに対応させたものです。公式のコードではありません。
|
| 3 |
+
https://github.com/huggingface/huggingface_hub/blob/main/src/huggingface_hub/_webhooks_payload.py
|
| 4 |
+
|
| 5 |
+
まだ、Issue Objectのごく一部しか対応してません。実際には、設定次第でissue以外のデーターも飛んでくるようになりますが、対応してません。(Unionとか使うぽい)
|
| 6 |
+
https://studio.apollographql.com/public/Linear-API/variant/current/schema/reference/objects/Issue
|
| 7 |
+
|
| 8 |
+
Issueの新規・更新・削除は確認しました。
|
| 9 |
+
|
| 10 |
+
そして、変更が激しい部分なので、将来 属性が、deprecatedからremovedになりエラーが出るかもしれません。
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
** Linear.app 対応部分の著作権表示 **
|
| 14 |
+
# Copyright 2025-present, Akihito Miyazaki
|
| 15 |
+
#
|
| 16 |
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 17 |
+
# you may not use this file except in compliance with the License.
|
| 18 |
+
# You may obtain a copy of the License at
|
| 19 |
+
#
|
| 20 |
+
# http://www.apache.org/licenses/LICENSE-2.0
|
| 21 |
+
#
|
| 22 |
+
# Unless required by applicable law or agreed to in writing, software
|
| 23 |
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 24 |
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 25 |
+
# See the License for the specific language governing permissions and
|
| 26 |
+
# limitations under the License.
|
| 27 |
+
|
| 28 |
+
** Hugging Face Hub ライブラリのライセンス表示 **
|
| 29 |
+
# Copyright 2023-present, the HuggingFace Inc. team.
|
| 30 |
+
#
|
| 31 |
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 32 |
+
# you may not use this file except in compliance with the License.
|
| 33 |
+
# You may obtain a copy of the License at
|
| 34 |
+
#
|
| 35 |
+
# http://www.apache.org/licenses/LICENSE-2.0
|
| 36 |
+
#
|
| 37 |
+
# Unless required by applicable law or agreed to in writing, software
|
| 38 |
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 39 |
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 40 |
+
# See the License for the specific language governing permissions and
|
| 41 |
+
# limitations under the License.
|
| 42 |
+
|
| 43 |
+
このコードには Hugging Face Hub ライブラリの一部が含まれており、Apache License, Version 2.0 の下でライセンスされています。
|
| 44 |
+
ライセンスの全文は以下から確認できます: http://www.apache.org/licenses/LICENSE-2.0
|
| 45 |
+
"""
|
| 46 |
+
|
| 47 |
+
"""Contains data structures to parse the webhooks payload."""
|
| 48 |
+
|
| 49 |
+
from typing import List, Literal, Optional
|
| 50 |
+
from datetime import datetime
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def is_pydantic_available():
|
| 54 |
+
return True
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
if is_pydantic_available():
|
| 58 |
+
from pydantic import BaseModel
|
| 59 |
+
else:
|
| 60 |
+
# Define a dummy BaseModel to avoid import errors when pydantic is not installed
|
| 61 |
+
# Import error will be raised when trying to use the class
|
| 62 |
+
|
| 63 |
+
class BaseModel: # type: ignore [no-redef]
|
| 64 |
+
def __init__(self, *args, **kwargs) -> None:
|
| 65 |
+
raise ImportError(
|
| 66 |
+
"You must have `pydantic` installed to use `WebhookPayload`. This is an optional dependency that"
|
| 67 |
+
" should be installed separately. Please run `pip install --upgrade pydantic` and retry."
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
# This is an adaptation of the ReportV3 interface implemented in moon-landing. V0, V1 and V2 have been ignored as they
|
| 72 |
+
# are not in used anymore. To keep in sync when format is updated in
|
| 73 |
+
# https://github.com/huggingface/moon-landing/blob/main/server/lib/HFWebhooks.ts (internal link).
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class WebhookPayloadUploadFrom(BaseModel):
|
| 77 |
+
stateId: Optional[str] = None
|
| 78 |
+
updatedAt: datetime
|
| 79 |
+
description: Optional[str] = None
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class WebhookPayloadTeam(BaseModel):
|
| 83 |
+
id: str
|
| 84 |
+
name: str
|
| 85 |
+
key: str
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class WebhookPayloadProject(BaseModel):
|
| 89 |
+
id: str
|
| 90 |
+
name: str
|
| 91 |
+
url: str
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
class WebhookPayloadState(BaseModel):
|
| 95 |
+
id: str
|
| 96 |
+
color: str
|
| 97 |
+
name: str
|
| 98 |
+
type: str
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
class WebhookPayloadLabel(BaseModel):
|
| 102 |
+
id: str
|
| 103 |
+
color: str
|
| 104 |
+
name: str
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
class WebhookPayloadData(BaseModel):
|
| 108 |
+
id: str
|
| 109 |
+
createdAt: datetime
|
| 110 |
+
updatedAt: datetime
|
| 111 |
+
archivedAt: Optional[datetime] = None
|
| 112 |
+
title: str
|
| 113 |
+
description: Optional[str] = None
|
| 114 |
+
labels: List[WebhookPayloadLabel] = []
|
| 115 |
+
priority: int
|
| 116 |
+
estimate: Optional[int] = None
|
| 117 |
+
startedAt: Optional[datetime] = None
|
| 118 |
+
state: WebhookPayloadState
|
| 119 |
+
team: WebhookPayloadTeam
|
| 120 |
+
project: Optional[WebhookPayloadProject] = None
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
class WebhookPayload(BaseModel):
|
| 124 |
+
action: str
|
| 125 |
+
type: str
|
| 126 |
+
createdAt: str
|
| 127 |
+
data: WebhookPayloadData
|
| 128 |
+
url: Optional[str] = None
|
| 129 |
+
webhookTimestamp: int
|
| 130 |
+
webhookId: str
|
| 131 |
+
updatedFrom: Optional[WebhookPayloadUploadFrom] = None
|
gradio_webhook_server.py
ADDED
|
@@ -0,0 +1,505 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
このコードは以下のコードをLinear.appに対応させたものです。公式のコードではありません。
|
| 3 |
+
Local Gradioの動作のみ確認・Spaceでのテストはまだしていません。
|
| 4 |
+
特に verify_signature 関数と、webhook 呼び出し時の request ヘッダーの取り扱いを変更しています。
|
| 5 |
+
https://github.com/huggingface/huggingface_hub/blob/main/src/huggingface_hub/_webhooks_server.py
|
| 6 |
+
|
| 7 |
+
なおリクエストのデバッグのため以下に上書きで書き出しを行っています。
|
| 8 |
+
webhook_header.json
|
| 9 |
+
webhook_request.json
|
| 10 |
+
|
| 11 |
+
** Linear.app 対応部分の著作権表示 **
|
| 12 |
+
# Copyright 2025-present, Akihito Miyazaki
|
| 13 |
+
#
|
| 14 |
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 15 |
+
# you may not use this file except in compliance with the License.
|
| 16 |
+
# You may obtain a copy of the License at
|
| 17 |
+
#
|
| 18 |
+
# http://www.apache.org/licenses/LICENSE-2.0
|
| 19 |
+
#
|
| 20 |
+
# Unless required by applicable law or agreed to in writing, software
|
| 21 |
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 22 |
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 23 |
+
# See the License for the specific language governing permissions and
|
| 24 |
+
# limitations under the License.
|
| 25 |
+
|
| 26 |
+
** Hugging Face Hub ライブラリのライセンス表示 **
|
| 27 |
+
# Copyright 2023-present, the HuggingFace Inc. team.
|
| 28 |
+
#
|
| 29 |
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 30 |
+
# you may not use this file except in compliance with the License.
|
| 31 |
+
# You may obtain a copy of the License at
|
| 32 |
+
#
|
| 33 |
+
# http://www.apache.org/licenses/LICENSE-2.0
|
| 34 |
+
#
|
| 35 |
+
# Unless required by applicable law or agreed to in writing, software
|
| 36 |
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 37 |
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 38 |
+
# See the License for the specific language governing permissions and
|
| 39 |
+
# limitations under the License.
|
| 40 |
+
|
| 41 |
+
このコードには Hugging Face Hub ライブラリの一部が含まれており、Apache License, Version 2.0 の下でライセンスされています。
|
| 42 |
+
ライセンスの全文は以下から確認できます: http://www.apache.org/licenses/LICENSE-2.0
|
| 43 |
+
"""
|
| 44 |
+
|
| 45 |
+
"""Contains `WebhooksServer` and `webhook_endpoint` to create a webhook server easily."""
|
| 46 |
+
|
| 47 |
+
import atexit
|
| 48 |
+
import inspect
|
| 49 |
+
import os
|
| 50 |
+
from functools import wraps
|
| 51 |
+
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional
|
| 52 |
+
|
| 53 |
+
import hashlib
|
| 54 |
+
import hmac
|
| 55 |
+
import json
|
| 56 |
+
from fastapi.encoders import jsonable_encoder
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def verify_signature(request_headers, payload, webhook_secret):
|
| 60 |
+
"""
|
| 61 |
+
リクエストヘッダーの署名と、ペイロードから生成した署名を比較して、リクエストが有効かどうかを検証します。
|
| 62 |
+
|
| 63 |
+
Args:
|
| 64 |
+
request_headers: リクエストヘッダー (dict-like object, e.g., `request.headers`)
|
| 65 |
+
payload: リクエストのペイロード (bytes or string)
|
| 66 |
+
webhook_secret: ウェブフックのシークレットキー (string)
|
| 67 |
+
|
| 68 |
+
Returns:
|
| 69 |
+
True: 署名が一致する場合 (リクエストは有効)
|
| 70 |
+
False: 署名が一致しない場合 (リクエストは無効)
|
| 71 |
+
"""
|
| 72 |
+
|
| 73 |
+
# 環境変数からWEBHOOK_SECRETを取得(Netlify.env.get('WEBHOOK_SECRET')の代替)
|
| 74 |
+
# 例:webhook_secret = os.environ.get('WEBHOOK_SECRET')
|
| 75 |
+
# 実際の環境変数名に合わせてください
|
| 76 |
+
|
| 77 |
+
# ペイロードが文字列ならバイト列に変換
|
| 78 |
+
if isinstance(payload, str):
|
| 79 |
+
payload = payload.encode("utf-8")
|
| 80 |
+
|
| 81 |
+
# HMAC署名を生成
|
| 82 |
+
signature = hmac.new(
|
| 83 |
+
webhook_secret.encode("utf-8"), # シークレットキーはバイト列にする
|
| 84 |
+
payload,
|
| 85 |
+
hashlib.sha256,
|
| 86 |
+
).hexdigest()
|
| 87 |
+
|
| 88 |
+
# リクエストヘッダーから署名を取得
|
| 89 |
+
# ヘッダー名は大文字小文字を区別しない場合があるので注意
|
| 90 |
+
linear_signature = request_headers.get("linear-signature")
|
| 91 |
+
if not linear_signature:
|
| 92 |
+
linear_signature = request_headers.get(
|
| 93 |
+
"Linear-Signature"
|
| 94 |
+
) # ヘッダー名の大文字小文字のバリエーションもチェック
|
| 95 |
+
if not linear_signature:
|
| 96 |
+
print("Error: linear-signature header not found")
|
| 97 |
+
return False
|
| 98 |
+
|
| 99 |
+
# 署名を比較
|
| 100 |
+
return hmac.compare_digest(signature, linear_signature) # 脆弱性対策
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
# from .utils import experimental, is_fastapi_available, is_gradio_available
|
| 104 |
+
# skip check
|
| 105 |
+
def is_fastapi_available():
|
| 106 |
+
return True
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def is_gradio_available():
|
| 110 |
+
return True
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
if TYPE_CHECKING:
|
| 114 |
+
import gradio as gr
|
| 115 |
+
from fastapi import Request
|
| 116 |
+
|
| 117 |
+
if is_fastapi_available():
|
| 118 |
+
from fastapi import FastAPI, Request
|
| 119 |
+
from fastapi.responses import JSONResponse
|
| 120 |
+
else:
|
| 121 |
+
# Will fail at runtime if FastAPI is not available
|
| 122 |
+
FastAPI = Request = JSONResponse = None # type: ignore [misc, assignment]
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
_global_app: Optional["WebhooksServer"] = None
|
| 126 |
+
_is_local = os.environ.get("SPACE_ID") is None
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
class WebhooksServer:
|
| 130 |
+
"""
|
| 131 |
+
The [`WebhooksServer`] class lets you create an instance of a Gradio app that can receive Huggingface webhooks.
|
| 132 |
+
These webhooks can be registered using the [`~WebhooksServer.add_webhook`] decorator. Webhook endpoints are added to
|
| 133 |
+
the app as a POST endpoint to the FastAPI router. Once all the webhooks are registered, the `launch` method has to be
|
| 134 |
+
called to start the app.
|
| 135 |
+
|
| 136 |
+
It is recommended to accept [`WebhookPayload`] as the first argument of the webhook function. It is a Pydantic
|
| 137 |
+
model that contains all the information about the webhook event. The data will be parsed automatically for you.
|
| 138 |
+
|
| 139 |
+
Check out the [webhooks guide](../guides/webhooks_server) for a step-by-step tutorial on how to setup your
|
| 140 |
+
WebhooksServer and deploy it on a Space.
|
| 141 |
+
|
| 142 |
+
<Tip warning={true}>
|
| 143 |
+
|
| 144 |
+
`WebhooksServer` is experimental. Its API is subject to change in the future.
|
| 145 |
+
|
| 146 |
+
</Tip>
|
| 147 |
+
|
| 148 |
+
<Tip warning={true}>
|
| 149 |
+
|
| 150 |
+
You must have `gradio` installed to use `WebhooksServer` (`pip install --upgrade gradio`).
|
| 151 |
+
|
| 152 |
+
</Tip>
|
| 153 |
+
|
| 154 |
+
Args:
|
| 155 |
+
ui (`gradio.Blocks`, optional):
|
| 156 |
+
A Gradio UI instance to be used as the Space landing page. If `None`, a UI displaying instructions
|
| 157 |
+
about the configured webhooks is created.
|
| 158 |
+
webhook_secret (`str`, optional):
|
| 159 |
+
A secret key to verify incoming webhook requests. You can set this value to any secret you want as long as
|
| 160 |
+
you also configure it in your [webhooks settings panel](https://huggingface.co/settings/webhooks). You
|
| 161 |
+
can also set this value as the `WEBHOOK_SECRET` environment variable. If no secret is provided, the
|
| 162 |
+
webhook endpoints are opened without any security.
|
| 163 |
+
|
| 164 |
+
Example:
|
| 165 |
+
|
| 166 |
+
```python
|
| 167 |
+
import gradio as gr
|
| 168 |
+
from huggingface_hub import WebhooksServer, WebhookPayload
|
| 169 |
+
|
| 170 |
+
with gr.Blocks() as ui:
|
| 171 |
+
...
|
| 172 |
+
|
| 173 |
+
app = WebhooksServer(ui=ui, webhook_secret="my_secret_key")
|
| 174 |
+
|
| 175 |
+
@app.add_webhook("/say_hello")
|
| 176 |
+
async def hello(payload: WebhookPayload):
|
| 177 |
+
return {"message": "hello"}
|
| 178 |
+
|
| 179 |
+
app.launch()
|
| 180 |
+
```
|
| 181 |
+
"""
|
| 182 |
+
|
| 183 |
+
def __new__(cls, *args, **kwargs) -> "WebhooksServer":
|
| 184 |
+
if not is_gradio_available():
|
| 185 |
+
raise ImportError(
|
| 186 |
+
"You must have `gradio` installed to use `WebhooksServer`. Please run `pip install --upgrade gradio`"
|
| 187 |
+
" first."
|
| 188 |
+
)
|
| 189 |
+
if not is_fastapi_available():
|
| 190 |
+
raise ImportError(
|
| 191 |
+
"You must have `fastapi` installed to use `WebhooksServer`. Please run `pip install --upgrade fastapi`"
|
| 192 |
+
" first."
|
| 193 |
+
)
|
| 194 |
+
return super().__new__(cls)
|
| 195 |
+
|
| 196 |
+
def __init__(
|
| 197 |
+
self,
|
| 198 |
+
ui: Optional["gr.Blocks"] = None,
|
| 199 |
+
webhook_secret: Optional[str] = None,
|
| 200 |
+
) -> None:
|
| 201 |
+
self._ui = ui
|
| 202 |
+
|
| 203 |
+
self.webhook_secret = webhook_secret or os.getenv("WEBHOOK_SECRET")
|
| 204 |
+
self.registered_webhooks: Dict[str, Callable] = {}
|
| 205 |
+
_warn_on_empty_secret(self.webhook_secret)
|
| 206 |
+
|
| 207 |
+
def add_webhook(self, path: Optional[str] = None) -> Callable:
|
| 208 |
+
"""
|
| 209 |
+
Decorator to add a webhook to the [`WebhooksServer`] server.
|
| 210 |
+
|
| 211 |
+
Args:
|
| 212 |
+
path (`str`, optional):
|
| 213 |
+
The URL path to register the webhook function. If not provided, the function name will be used as the
|
| 214 |
+
path. In any case, all webhooks are registered under `/webhooks`.
|
| 215 |
+
|
| 216 |
+
Raises:
|
| 217 |
+
ValueError: If the provided path is already registered as a webhook.
|
| 218 |
+
|
| 219 |
+
Example:
|
| 220 |
+
```python
|
| 221 |
+
from huggingface_hub import WebhooksServer, WebhookPayload
|
| 222 |
+
|
| 223 |
+
app = WebhooksServer()
|
| 224 |
+
|
| 225 |
+
@app.add_webhook
|
| 226 |
+
async def trigger_training(payload: WebhookPayload):
|
| 227 |
+
if payload.repo.type == "dataset" and payload.event.action == "update":
|
| 228 |
+
# Trigger a training job if a dataset is updated
|
| 229 |
+
...
|
| 230 |
+
|
| 231 |
+
app.launch()
|
| 232 |
+
```
|
| 233 |
+
"""
|
| 234 |
+
# Usage: directly as decorator. Example: `@app.add_webhook`
|
| 235 |
+
if callable(path):
|
| 236 |
+
# If path is a function, it means it was used as a decorator without arguments
|
| 237 |
+
return self.add_webhook()(path)
|
| 238 |
+
|
| 239 |
+
# Usage: provide a path. Example: `@app.add_webhook(...)`
|
| 240 |
+
@wraps(FastAPI.post)
|
| 241 |
+
def _inner_post(*args, **kwargs):
|
| 242 |
+
func = args[0]
|
| 243 |
+
abs_path = f"/webhooks/{(path or func.__name__).strip('/')}"
|
| 244 |
+
if abs_path in self.registered_webhooks:
|
| 245 |
+
raise ValueError(f"Webhook {abs_path} already exists.")
|
| 246 |
+
self.registered_webhooks[abs_path] = func
|
| 247 |
+
|
| 248 |
+
return _inner_post
|
| 249 |
+
|
| 250 |
+
def launch(
|
| 251 |
+
self,
|
| 252 |
+
prevent_thread_lock: bool = False,
|
| 253 |
+
webhook_update=None,
|
| 254 |
+
**launch_kwargs: Any,
|
| 255 |
+
) -> None:
|
| 256 |
+
"""Launch the Gradio app and register webhooks to the underlying FastAPI server.
|
| 257 |
+
|
| 258 |
+
Input parameters are forwarded to Gradio when launching the app.
|
| 259 |
+
"""
|
| 260 |
+
ui = self._ui or self._get_default_ui()
|
| 261 |
+
|
| 262 |
+
# Start Gradio App
|
| 263 |
+
# - as non-blocking so that webhooks can be added afterwards
|
| 264 |
+
# - as shared if launch locally (to debug webhooks)
|
| 265 |
+
launch_kwargs.setdefault("share", _is_local)
|
| 266 |
+
self.fastapi_app, _, _ = ui.launch(prevent_thread_lock=True, **launch_kwargs)
|
| 267 |
+
|
| 268 |
+
# Register webhooks to FastAPI app
|
| 269 |
+
for path, func in self.registered_webhooks.items():
|
| 270 |
+
# Add secret check if required
|
| 271 |
+
if self.webhook_secret is not None:
|
| 272 |
+
func = _wrap_webhook_to_check_secret(
|
| 273 |
+
func, webhook_secret=self.webhook_secret
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
# Add route to FastAPI app
|
| 277 |
+
self.fastapi_app.post(path)(func)
|
| 278 |
+
|
| 279 |
+
# Print instructions and block main thread
|
| 280 |
+
space_host = os.environ.get("SPACE_HOST")
|
| 281 |
+
url = (
|
| 282 |
+
"https://" + space_host
|
| 283 |
+
if space_host is not None
|
| 284 |
+
else (ui.share_url or ui.local_url)
|
| 285 |
+
)
|
| 286 |
+
url = url.strip("/")
|
| 287 |
+
message = "\nWebhooks are correctly setup and ready to use:"
|
| 288 |
+
message += "\n" + "\n".join(
|
| 289 |
+
f" - POST {url}{webhook}" for webhook in self.registered_webhooks
|
| 290 |
+
)
|
| 291 |
+
message += (
|
| 292 |
+
"\nGo to https://huggingface.co/settings/webhooks to setup your webhooks."
|
| 293 |
+
)
|
| 294 |
+
# print(message)
|
| 295 |
+
gradio_url = f"{url}{next(iter(self.registered_webhooks))}"
|
| 296 |
+
# print(gradio_url)
|
| 297 |
+
webhook_update(gradio_url)
|
| 298 |
+
|
| 299 |
+
if not prevent_thread_lock:
|
| 300 |
+
ui.block_thread()
|
| 301 |
+
|
| 302 |
+
def _get_default_ui(self) -> "gr.Blocks":
|
| 303 |
+
"""Default UI if not provided (lists webhooks and provides basic instructions)."""
|
| 304 |
+
import gradio as gr
|
| 305 |
+
|
| 306 |
+
with gr.Blocks() as ui:
|
| 307 |
+
gr.Markdown("# This is an app to process 🤗 Webhooks")
|
| 308 |
+
gr.Markdown(
|
| 309 |
+
"Webhooks are a foundation for MLOps-related features. They allow you to listen for new changes on"
|
| 310 |
+
" specific repos or to all repos belonging to particular set of users/organizations (not just your"
|
| 311 |
+
" repos, but any repo). Check out this [guide](https://huggingface.co/docs/hub/webhooks) to get to"
|
| 312 |
+
" know more about webhooks on the Huggingface Hub."
|
| 313 |
+
)
|
| 314 |
+
gr.Markdown(
|
| 315 |
+
f"{len(self.registered_webhooks)} webhook(s) are registered:"
|
| 316 |
+
+ "\n\n"
|
| 317 |
+
+ "\n ".join(
|
| 318 |
+
f"- [{webhook_path}]({_get_webhook_doc_url(webhook.__name__, webhook_path)})"
|
| 319 |
+
for webhook_path, webhook in self.registered_webhooks.items()
|
| 320 |
+
)
|
| 321 |
+
)
|
| 322 |
+
gr.Markdown(
|
| 323 |
+
"Go to https://huggingface.co/settings/webhooks to setup your webhooks."
|
| 324 |
+
+ "\nYou app is running locally. Please look at the logs to check the full URL you need to set."
|
| 325 |
+
if _is_local
|
| 326 |
+
else (
|
| 327 |
+
"\nThis app is running on a Space. You can find the corresponding URL in the options menu"
|
| 328 |
+
" (top-right) > 'Embed the Space'. The URL looks like 'https://{username}-{repo_name}.hf.space'."
|
| 329 |
+
)
|
| 330 |
+
)
|
| 331 |
+
return ui
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
def webhook_endpoint(path: Optional[str] = None) -> Callable:
|
| 335 |
+
"""Decorator to start a [`WebhooksServer`] and register the decorated function as a webhook endpoint.
|
| 336 |
+
|
| 337 |
+
This is a helper to get started quickly. If you need more flexibility (custom landing page or webhook secret),
|
| 338 |
+
you can use [`WebhooksServer`] directly. You can register multiple webhook endpoints (to the same server) by using
|
| 339 |
+
this decorator multiple times.
|
| 340 |
+
|
| 341 |
+
Check out the [webhooks guide](../guides/webhooks_server) for a step-by-step tutorial on how to setup your
|
| 342 |
+
server and deploy it on a Space.
|
| 343 |
+
|
| 344 |
+
<Tip warning={true}>
|
| 345 |
+
|
| 346 |
+
`webhook_endpoint` is experimental. Its API is subject to change in the future.
|
| 347 |
+
|
| 348 |
+
</Tip>
|
| 349 |
+
|
| 350 |
+
<Tip warning={true}>
|
| 351 |
+
|
| 352 |
+
You must have `gradio` installed to use `webhook_endpoint` (`pip install --upgrade gradio`).
|
| 353 |
+
|
| 354 |
+
</Tip>
|
| 355 |
+
|
| 356 |
+
Args:
|
| 357 |
+
path (`str`, optional):
|
| 358 |
+
The URL path to register the webhook function. If not provided, the function name will be used as the path.
|
| 359 |
+
In any case, all webhooks are registered under `/webhooks`.
|
| 360 |
+
|
| 361 |
+
Examples:
|
| 362 |
+
The default usage is to register a function as a webhook endpoint. The function name will be used as the path.
|
| 363 |
+
The server will be started automatically at exit (i.e. at the end of the script).
|
| 364 |
+
|
| 365 |
+
```python
|
| 366 |
+
from huggingface_hub import webhook_endpoint, WebhookPayload
|
| 367 |
+
|
| 368 |
+
@webhook_endpoint
|
| 369 |
+
async def trigger_training(payload: WebhookPayload):
|
| 370 |
+
if payload.repo.type == "dataset" and payload.event.action == "update":
|
| 371 |
+
# Trigger a training job if a dataset is updated
|
| 372 |
+
...
|
| 373 |
+
|
| 374 |
+
# Server is automatically started at the end of the script.
|
| 375 |
+
```
|
| 376 |
+
|
| 377 |
+
Advanced usage: register a function as a webhook endpoint and start the server manually. This is useful if you
|
| 378 |
+
are running it in a notebook.
|
| 379 |
+
|
| 380 |
+
```python
|
| 381 |
+
from huggingface_hub import webhook_endpoint, WebhookPayload
|
| 382 |
+
|
| 383 |
+
@webhook_endpoint
|
| 384 |
+
async def trigger_training(payload: WebhookPayload):
|
| 385 |
+
if payload.repo.type == "dataset" and payload.event.action == "update":
|
| 386 |
+
# Trigger a training job if a dataset is updated
|
| 387 |
+
...
|
| 388 |
+
|
| 389 |
+
# Start the server manually
|
| 390 |
+
trigger_training.launch()
|
| 391 |
+
```
|
| 392 |
+
"""
|
| 393 |
+
if callable(path):
|
| 394 |
+
# If path is a function, it means it was used as a decorator without arguments
|
| 395 |
+
return webhook_endpoint()(path)
|
| 396 |
+
|
| 397 |
+
@wraps(WebhooksServer.add_webhook)
|
| 398 |
+
def _inner(func: Callable) -> Callable:
|
| 399 |
+
app = _get_global_app()
|
| 400 |
+
app.add_webhook(path)(func)
|
| 401 |
+
if len(app.registered_webhooks) == 1:
|
| 402 |
+
# Register `app.launch` to run at exit (only once)
|
| 403 |
+
atexit.register(app.launch)
|
| 404 |
+
|
| 405 |
+
@wraps(app.launch)
|
| 406 |
+
def _launch_now():
|
| 407 |
+
# Run the app directly (without waiting atexit)
|
| 408 |
+
atexit.unregister(app.launch)
|
| 409 |
+
app.launch()
|
| 410 |
+
|
| 411 |
+
func.launch = _launch_now # type: ignore
|
| 412 |
+
return func
|
| 413 |
+
|
| 414 |
+
return _inner
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
def _get_global_app() -> WebhooksServer:
|
| 418 |
+
global _global_app
|
| 419 |
+
if _global_app is None:
|
| 420 |
+
_global_app = WebhooksServer()
|
| 421 |
+
return _global_app
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
def _warn_on_empty_secret(webhook_secret: Optional[str]) -> None:
|
| 425 |
+
if webhook_secret is None:
|
| 426 |
+
print(
|
| 427 |
+
"Webhook secret is not defined. This means your webhook endpoints will be open to everyone."
|
| 428 |
+
)
|
| 429 |
+
print(
|
| 430 |
+
"To add a secret, set `WEBHOOK_SECRET` as environment variable or pass it at initialization: "
|
| 431 |
+
"\n\t`app = WebhooksServer(webhook_secret='my_secret', ...)`"
|
| 432 |
+
)
|
| 433 |
+
print(
|
| 434 |
+
"For more details about webhook secrets, please refer to"
|
| 435 |
+
" https://huggingface.co/docs/hub/webhooks#webhook-secret."
|
| 436 |
+
)
|
| 437 |
+
else:
|
| 438 |
+
print("Webhook secret is correctly defined.")
|
| 439 |
+
|
| 440 |
+
|
| 441 |
+
def _get_webhook_doc_url(webhook_name: str, webhook_path: str) -> str:
|
| 442 |
+
"""Returns the anchor to a given webhook in the docs (experimental)"""
|
| 443 |
+
return "/docs#/default/" + webhook_name + webhook_path.replace("/", "_") + "_post"
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
def _wrap_webhook_to_check_secret(func: Callable, webhook_secret: str) -> Callable:
|
| 447 |
+
"""Wraps a webhook function to check the webhook secret before calling the function.
|
| 448 |
+
|
| 449 |
+
This is a hacky way to add the `request` parameter to the function signature. Since FastAPI based itself on route
|
| 450 |
+
parameters to inject the values to the function, we need to hack the function signature to retrieve the `Request`
|
| 451 |
+
object (and hence the headers). A far cleaner solution would be to use a middleware. However, since
|
| 452 |
+
`fastapi==0.90.1`, a middleware cannot be added once the app has started. And since the FastAPI app is started by
|
| 453 |
+
Gradio internals (and not by us), we cannot add a middleware.
|
| 454 |
+
|
| 455 |
+
This method is called only when a secret has been defined by the user. If a request is sent without the
|
| 456 |
+
"x-webhook-secret", the function will return a 401 error (unauthorized). If the header is sent but is incorrect,
|
| 457 |
+
the function will return a 403 error (forbidden).
|
| 458 |
+
|
| 459 |
+
Inspired by https://stackoverflow.com/a/33112180.
|
| 460 |
+
"""
|
| 461 |
+
initial_sig = inspect.signature(func)
|
| 462 |
+
|
| 463 |
+
@wraps(func)
|
| 464 |
+
async def _protected_func(request: Request, **kwargs):
|
| 465 |
+
request_text = await request.body()
|
| 466 |
+
|
| 467 |
+
data = json.loads(request_text.decode())
|
| 468 |
+
|
| 469 |
+
# ファイルに保存(インデント付き)
|
| 470 |
+
with open("webhook_header.json", "w", encoding="utf-8") as f:
|
| 471 |
+
serialized = jsonable_encoder(request.headers)
|
| 472 |
+
json.dump(serialized, f, indent=2, ensure_ascii=False)
|
| 473 |
+
with open("webhook_request.json", "w", encoding="utf-8") as f:
|
| 474 |
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
| 475 |
+
|
| 476 |
+
result = verify_signature(request.headers, request_text, webhook_secret)
|
| 477 |
+
if not result:
|
| 478 |
+
print("invalid signature")
|
| 479 |
+
return JSONResponse({"error": "Invalid webhook secret."}, status_code=403)
|
| 480 |
+
|
| 481 |
+
# Inject `request` in kwargs if required
|
| 482 |
+
if "request" in initial_sig.parameters:
|
| 483 |
+
kwargs["request"] = request
|
| 484 |
+
|
| 485 |
+
# Handle both sync and async routes
|
| 486 |
+
if inspect.iscoroutinefunction(func):
|
| 487 |
+
return await func(**kwargs)
|
| 488 |
+
else:
|
| 489 |
+
return func(**kwargs)
|
| 490 |
+
|
| 491 |
+
# Update signature to include request
|
| 492 |
+
if "request" not in initial_sig.parameters:
|
| 493 |
+
_protected_func.__signature__ = initial_sig.replace( # type: ignore
|
| 494 |
+
parameters=(
|
| 495 |
+
inspect.Parameter(
|
| 496 |
+
name="request",
|
| 497 |
+
kind=inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
| 498 |
+
annotation=Request,
|
| 499 |
+
),
|
| 500 |
+
)
|
| 501 |
+
+ tuple(initial_sig.parameters.values())
|
| 502 |
+
)
|
| 503 |
+
|
| 504 |
+
# Return protected route
|
| 505 |
+
return _protected_func
|
linear_api_utils.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This code is licensed under the MIT License.
|
| 2 |
+
# Copyright (c) [2025] [Akihito Miyazaki]
|
| 3 |
+
#
|
| 4 |
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 5 |
+
# of this software and associated documentation files (the "Software"), to deal
|
| 6 |
+
# in the Software without restriction, including without limitation the rights
|
| 7 |
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 8 |
+
# copies of the Software, and to permit persons to whom the Software is
|
| 9 |
+
# furnished to do so, subject to the following conditions:
|
| 10 |
+
#
|
| 11 |
+
# The above copyright notice and this permission notice shall be included in all
|
| 12 |
+
# copies or substantial portions of the Software.
|
| 13 |
+
#
|
| 14 |
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 15 |
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 16 |
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 17 |
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 18 |
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 19 |
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 20 |
+
# SOFTWARE.
|
| 21 |
+
#
|
| 22 |
+
|
| 23 |
+
import json
|
| 24 |
+
import os
|
| 25 |
+
import time
|
| 26 |
+
|
| 27 |
+
# from dotenv import load_dotenv
|
| 28 |
+
from pprint import pprint
|
| 29 |
+
import requests
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def request_linear(
|
| 33 |
+
headers, data, url="https://api.linear.app/graphql", print_header=False
|
| 34 |
+
):
|
| 35 |
+
response_data = None
|
| 36 |
+
try:
|
| 37 |
+
response = requests.post(url, headers=headers, json=data)
|
| 38 |
+
|
| 39 |
+
response_data = response.json()
|
| 40 |
+
if print_header:
|
| 41 |
+
print("--- ヘッダーの表示開始 ---")
|
| 42 |
+
pprint(dict(response.headers), indent=4)
|
| 43 |
+
print("--- ヘッダーの表示終了 ---")
|
| 44 |
+
|
| 45 |
+
response.raise_for_status() # ステータスコードが200番台以外の場合に例外を発生させる
|
| 46 |
+
return response_data
|
| 47 |
+
except requests.exceptions.RequestException as e:
|
| 48 |
+
print(response_data)
|
| 49 |
+
print(f"エラーが発生しました: {e}")
|
| 50 |
+
exit(0)
|
| 51 |
+
except json.JSONDecodeError as e:
|
| 52 |
+
print(f"JSONデコードエラー: {e}")
|
| 53 |
+
print(f"レスポンス内容:\n{response.text}")
|
| 54 |
+
exit(0)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def load_api_key(dir="./"):
|
| 58 |
+
print(f"{dir}.env")
|
| 59 |
+
load_dotenv(dotenv_path=f"{dir}.env")
|
| 60 |
+
if "api_key" in os.environ:
|
| 61 |
+
api_key = os.environ["api_key"]
|
| 62 |
+
return api_key
|
| 63 |
+
else:
|
| 64 |
+
print("'api_key' が環境変数にありません。")
|
| 65 |
+
print(".envファイルを作成し 以下の行を追加してください。")
|
| 66 |
+
print("api_key=your_api_key")
|
| 67 |
+
print("このファイルは.gitignoreに追加して、決して公開しないでください。")
|
| 68 |
+
print(
|
| 69 |
+
"Linear Settings Security&access - Personal API keysからAPI Keyは作成できます。"
|
| 70 |
+
)
|
| 71 |
+
exit(0)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def execute_query(label, query_text, authorization, print_header=False):
|
| 75 |
+
headers = {
|
| 76 |
+
"Content-Type": "application/json",
|
| 77 |
+
"Authorization": authorization,
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
start_time_total = time.time()
|
| 81 |
+
print(f"--- 処理の開始:{label} ({time.strftime('%Y-%m-%d %H:%M:%S')}) ---")
|
| 82 |
+
|
| 83 |
+
query_dic = {"query": query_text}
|
| 84 |
+
print("--- クエリの表示開始 ---")
|
| 85 |
+
print(f"{query_dic['query']}")
|
| 86 |
+
print("--- クエリの表示終了 ---")
|
| 87 |
+
result = request_linear(headers, query_dic, print_header=print_header)
|
| 88 |
+
print("--- 結果の表示開始 ---")
|
| 89 |
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
| 90 |
+
print("--- 結果の表示終了 ---")
|
| 91 |
+
end_time_total = time.time()
|
| 92 |
+
total_time = end_time_total - start_time_total
|
| 93 |
+
print(f"--- 処理の終了:{label} ---")
|
| 94 |
+
print(f"合計処理時間: {total_time:.4f} 秒")
|
| 95 |
+
|
| 96 |
+
print("") # spacer
|
| 97 |
+
|
| 98 |
+
return result
|
requirements.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
smolagents
|
| 2 |
+
dotenv
|
| 3 |
+
requests
|
sleep_per_last_token_model.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
from typing import List, Optional, Dict
|
| 3 |
+
|
| 4 |
+
from smolagents import OpenAIServerModel, LiteLLMModel, ChatMessage, Tool
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class SleepPerLastTokenModelLiteLLM(LiteLLMModel):
|
| 8 |
+
def __init__(self, sleep_factor: float = 0.01, **kwargs):
|
| 9 |
+
super().__init__(**kwargs)
|
| 10 |
+
self.sleep_factor = sleep_factor
|
| 11 |
+
|
| 12 |
+
def __call__(
|
| 13 |
+
self,
|
| 14 |
+
messages: List[Dict[str, str]],
|
| 15 |
+
stop_sequences: Optional[List[str]] = None,
|
| 16 |
+
grammar: Optional[str] = None,
|
| 17 |
+
tools_to_call_from: Optional[List[Tool]] = None,
|
| 18 |
+
**kwargs,
|
| 19 |
+
) -> ChatMessage:
|
| 20 |
+
if self.last_input_token_count is not None:
|
| 21 |
+
sleep_time = (
|
| 22 |
+
self.last_input_token_count + self.last_output_token_count
|
| 23 |
+
) * self.sleep_factor
|
| 24 |
+
print(f"Sleeping for {sleep_time:.2f} seconds...")
|
| 25 |
+
time.sleep(sleep_time)
|
| 26 |
+
|
| 27 |
+
return super().__call__(
|
| 28 |
+
messages, stop_sequences, grammar, tools_to_call_from, **kwargs
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# smolagents 1.9.2 not working
|
| 33 |
+
# Error value must be a string ?
|
| 34 |
+
"""
|
| 35 |
+
class SleepPerLastTokenModelOpenAI(OpenAIServerModel):
|
| 36 |
+
def __init__(self, sleep_factor: float = 0.01, **kwargs):
|
| 37 |
+
super().__init__(**kwargs)
|
| 38 |
+
self.sleep_factor = sleep_factor
|
| 39 |
+
|
| 40 |
+
def __call__(
|
| 41 |
+
self,
|
| 42 |
+
messages: List[Dict[str, str]],
|
| 43 |
+
stop_sequences: Optional[List[str]] = None,
|
| 44 |
+
grammar: Optional[str] = None,
|
| 45 |
+
tools_to_call_from: Optional[List[Tool]] = None,
|
| 46 |
+
**kwargs,
|
| 47 |
+
) -> ChatMessage:
|
| 48 |
+
if self.last_input_token_count is not None:
|
| 49 |
+
sleep_time = self.last_input_token_count * self.sleep_factor
|
| 50 |
+
print(f"Sleeping for {sleep_time:.2f} seconds...")
|
| 51 |
+
time.sleep(sleep_time)
|
| 52 |
+
|
| 53 |
+
return super().__call__(
|
| 54 |
+
messages, stop_sequences, grammar, tools_to_call_from, **kwargs
|
| 55 |
+
)
|
| 56 |
+
"""
|