bibibi12345 Akiyama301 commited on
Commit
4726024
·
verified ·
1 Parent(s): d33adfa

Upload 9 files (#1)

Browse files

- Upload 9 files (98483f7b2150f42adc1600d9129cd29aa2e7668f)


Co-authored-by: Akiyama <[email protected]>

Files changed (8) hide show
  1. .env.example +4 -0
  2. Dockerfile +26 -27
  3. README.md +9 -10
  4. docker-compose.yml +1 -1
  5. gitignore +32 -0
  6. main.py +835 -373
  7. models.py +3 -27
  8. requirements.txt +5 -5
.env.example ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ NOTION_COOKIE=YOUR_NOTION_COOKIE
2
+ NOTION_SPACE_ID=YOUR_NOTION_SPACE_ID
3
+ NOTION_ACTIVE_USER_HEADER=YOUR_NOTION_ACTIVE_USER_HEADER
4
+ PROXY_AUTH_TOKEN=123321
Dockerfile CHANGED
@@ -1,28 +1,27 @@
1
- # Use an official Python runtime as a parent image
2
- FROM python:3.10-slim
3
-
4
- # Set the working directory in the container
5
- WORKDIR /app
6
-
7
- # Copy the requirements file into the container at /app
8
- COPY requirements.txt .
9
-
10
- # Install any needed packages specified in requirements.txt
11
- # Use --no-cache-dir to reduce image size
12
- # Use --upgrade to ensure latest versions are installed
13
- RUN pip install --no-cache-dir --upgrade -r requirements.txt
14
-
15
- # Copy the current directory contents into the container at /app
16
- COPY main.py .
17
- COPY models.py .
18
-
19
- # Make port 8000 available to the world outside this container
20
- EXPOSE 7860
21
-
22
- # Define environment variables (placeholders, will be set at runtime)
23
- ENV NOTION_COOKIE=""
24
- ENV NOTION_SPACE_ID=""
25
-
26
- # Run uvicorn when the container launches
27
- # Use 0.0.0.0 to make it accessible externally
28
  CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
 
1
+ # Use an official Python runtime as a parent image
2
+ FROM python:3.13-slim
3
+
4
+ # Set the working directory in the container
5
+ WORKDIR /app
6
+
7
+ # Copy the requirements file into the container at /app
8
+ COPY requirements.txt .
9
+
10
+ # Install any needed packages specified in requirements.txt
11
+ # Use --no-cache-dir to reduce image size
12
+ # Use --upgrade to ensure latest versions are installed
13
+ RUN pip install --no-cache-dir --upgrade -r requirements.txt
14
+
15
+ # Copy the current directory contents into the container at /app
16
+ COPY main.py .
17
+
18
+ # Make port 8000 available to the world outside this container
19
+ EXPOSE 7860
20
+
21
+ # Define environment variables (placeholders, will be set at runtime)
22
+ ENV NOTION_COOKIE=""
23
+ ENV NOTION_SPACE_ID=""
24
+
25
+ # Run uvicorn when the container launches
26
+ # Use 0.0.0.0 to make it accessible externally
 
27
  CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -17,8 +17,8 @@ This project provides a FastAPI application that acts as a bridge between OpenAI
17
 
18
  The application requires the following environment variables to be set:
19
 
20
- * `NOTION_COOKIE`: Your Notion complete cookie value. This is used for authentication with the Notion API. You can typically find this in your browser's developer tools while logged into Notion.
21
- * `NOTION_SPACE_ID`: The ID of the Notion Space you want the API to interact with (`x-notion-space-id in header`).
22
  * `PROXY_AUTH_TOKEN` (Optional): The Bearer token required for authentication to access the API endpoints. If not set, it defaults to `default_token`.
23
  * `NOTION_ACTIVE_USER_HEADER` (Optional): If set, its value will be used for the `x-notion-active-user-header` in requests sent to the Notion API. If not set or empty, the header is omitted.
24
 
@@ -29,18 +29,17 @@ The application requires the following environment variables to be set:
29
  ```bash
30
  pip install -r requirements.txt
31
  ```
32
- 3. Create a `.env` file in the project root with your `NOTION_COOKIE`:
33
  ```dotenv
34
  NOTION_COOKIE="your_cookie_value_here"
35
  NOTION_SPACE_ID="your_space_id_here"
36
  # PROXY_AUTH_TOKEN="your_secure_token" # Optional, defaults to default_token
37
- # NOTION_ACTIVE_USER_HEADER="your_user_id" # Optional
38
  ```
39
  4. Run the application using Uvicorn:
40
  ```bash
41
  uvicorn main:app --reload --port 7860
42
  ```
43
- The server will be available at `http://localhost:7860`. You will need to provide the correct token (either the default `default_token` or the one set in `.env`) via an `Authorization: Bearer <token>` header. The `NOTION_SPACE_ID` will be loaded from the `.env` file.
44
 
45
  ## Running with Docker Compose (Recommended for Local Dev)
46
 
@@ -54,7 +53,7 @@ This method uses the `docker-compose.yml` file for a streamlined local developme
54
  ```
55
  * `--build`: Rebuilds the image if the `Dockerfile` or context has changed.
56
  * `-d`: Runs the container in detached mode (in the background).
57
- 4. The application will be accessible locally at `http://localhost:8139`. Environment variables like `NOTION_COOKIE` and `NOTION_SPACE_ID` will be loaded automatically from the `.env` file.
58
 
59
  To stop the service, run:
60
  ```bash
@@ -70,7 +69,7 @@ This method involves building and running the Docker container manually, passing
70
  docker build -t notion-api-bridge .
71
  ```
72
  2. **Run the Docker container:**
73
- Replace `"your_cookie_value"` with your actual Notion cookie.
74
  ```bash
75
  docker run -p 7860:7860 \
76
  -e NOTION_COOKIE="your_cookie_value" \
@@ -79,7 +78,7 @@ This method involves building and running the Docker container manually, passing
79
  # -e NOTION_ACTIVE_USER_HEADER="your_user_id" \ # Optional: Set the active user header
80
  notion-api-bridge
81
  ```
82
- The server will be available at `http://localhost:7860` (or whichever host port you mapped to the container's 7860). You will need to use the token provided in the `-e PROXY_AUTH_TOKEN` flag via an `Authorization: Bearer <token>` header for authentication. The `NOTION_SPACE_ID` is passed directly via the `-e` flag.
83
 
84
  ## Deploying to Hugging Face Spaces
85
 
@@ -89,12 +88,12 @@ This application is designed to be easily deployed as a Docker Space on Hugging
89
  2. **Upload Files:** Upload the `Dockerfile`, `main.py`, `models.py`, and `requirements.txt` to your Space repository. You can do this via the web interface or by cloning the repository and pushing the files. **Do not upload your `.env` file.**
90
  3. **Add Secrets:** In your Space settings, navigate to the "Secrets" section. Add two secrets:
91
  * `NOTION_COOKIE`: Paste your Notion `token_v2` cookie value.
92
- * `NOTION_SPACE_ID`: Paste the ID of the target Notion Space.
93
  * `PROXY_AUTH_TOKEN`: Paste the desired Bearer token for API authentication (e.g., a strong, generated token). If you omit this, the default `default_token` will be used.
94
  * `NOTION_ACTIVE_USER_HEADER` (Optional): Paste the user ID to be sent in the `x-notion-active-user-header`. If omitted, the header will not be sent.
95
  Hugging Face will securely inject these secrets as environment variables into your running container.
96
  4. **Deployment:** Hugging Face Spaces will automatically build the Docker image from your `Dockerfile` and run the container. It detects applications running on port 7860 (as specified in the `Dockerfile` and metadata).
97
- 5. **Accessing the API:** Once the Space is running, you can access the API endpoint at the Space's public URL, providing the token via an `Authorization: Bearer <token>` header. The token must match the `PROXY_AUTH_TOKEN` secret you set (or the default `default_token`). The `NOTION_SPACE_ID` will be used automatically based on the secret you configured.
98
 
99
  **Example using `curl` (replace `your_token` and URL):**
100
  ```bash
 
17
 
18
  The application requires the following environment variables to be set:
19
 
20
+ * `NOTION_COOKIE`: Your Notion `token_v2` cookie value. This is used for authentication with the Notion API. You can typically find this in your browser's developer tools while logged into Notion.
21
+ * `NOTION_SPACE_ID`: The ID of your Notion workspace. You can usually find this in the URL when browsing your Notion workspace (it's the part after your domain and before the first page ID, often a UUID).
22
  * `PROXY_AUTH_TOKEN` (Optional): The Bearer token required for authentication to access the API endpoints. If not set, it defaults to `default_token`.
23
  * `NOTION_ACTIVE_USER_HEADER` (Optional): If set, its value will be used for the `x-notion-active-user-header` in requests sent to the Notion API. If not set or empty, the header is omitted.
24
 
 
29
  ```bash
30
  pip install -r requirements.txt
31
  ```
32
+ 3. Create a `.env` file in the project root with your `NOTION_COOKIE` and `NOTION_SPACE_ID`:
33
  ```dotenv
34
  NOTION_COOKIE="your_cookie_value_here"
35
  NOTION_SPACE_ID="your_space_id_here"
36
  # PROXY_AUTH_TOKEN="your_secure_token" # Optional, defaults to default_token
 
37
  ```
38
  4. Run the application using Uvicorn:
39
  ```bash
40
  uvicorn main:app --reload --port 7860
41
  ```
42
+ The server will be available at `http://localhost:7860`. You will need to provide the correct token (either the default `default_token` or the one set in `.env`) via an `Authorization: Bearer <token>` header.
43
 
44
  ## Running with Docker Compose (Recommended for Local Dev)
45
 
 
53
  ```
54
  * `--build`: Rebuilds the image if the `Dockerfile` or context has changed.
55
  * `-d`: Runs the container in detached mode (in the background).
56
+ 4. The application will be accessible locally at `http://localhost:8139`.
57
 
58
  To stop the service, run:
59
  ```bash
 
69
  docker build -t notion-api-bridge .
70
  ```
71
  2. **Run the Docker container:**
72
+ Replace `"your_cookie_value"` and `"your_space_id"` with your actual Notion credentials.
73
  ```bash
74
  docker run -p 7860:7860 \
75
  -e NOTION_COOKIE="your_cookie_value" \
 
78
  # -e NOTION_ACTIVE_USER_HEADER="your_user_id" \ # Optional: Set the active user header
79
  notion-api-bridge
80
  ```
81
+ The server will be available at `http://localhost:7860` (or whichever host port you mapped to the container's 7860). You will need to use the token provided in the `-e PROXY_AUTH_TOKEN` flag via an `Authorization: Bearer <token>` header for authentication.
82
 
83
  ## Deploying to Hugging Face Spaces
84
 
 
88
  2. **Upload Files:** Upload the `Dockerfile`, `main.py`, `models.py`, and `requirements.txt` to your Space repository. You can do this via the web interface or by cloning the repository and pushing the files. **Do not upload your `.env` file.**
89
  3. **Add Secrets:** In your Space settings, navigate to the "Secrets" section. Add two secrets:
90
  * `NOTION_COOKIE`: Paste your Notion `token_v2` cookie value.
91
+ * `NOTION_SPACE_ID`: Paste your Notion Space ID.
92
  * `PROXY_AUTH_TOKEN`: Paste the desired Bearer token for API authentication (e.g., a strong, generated token). If you omit this, the default `default_token` will be used.
93
  * `NOTION_ACTIVE_USER_HEADER` (Optional): Paste the user ID to be sent in the `x-notion-active-user-header`. If omitted, the header will not be sent.
94
  Hugging Face will securely inject these secrets as environment variables into your running container.
95
  4. **Deployment:** Hugging Face Spaces will automatically build the Docker image from your `Dockerfile` and run the container. It detects applications running on port 7860 (as specified in the `Dockerfile` and metadata).
96
+ 5. **Accessing the API:** Once the Space is running, you can access the API endpoint at the Space's public URL, providing the token via an `Authorization: Bearer <token>` header. The token must match the `PROXY_AUTH_TOKEN` secret you set (or the default `default_token`).
97
 
98
  **Example using `curl` (replace `your_token` and URL):**
99
  ```bash
docker-compose.yml CHANGED
@@ -3,6 +3,6 @@ services:
3
  notion-bridge:
4
  build: .
5
  ports:
6
- - "8139:7860" # Map host port 8139 to container port 7860
7
  env_file:
8
  - .env
 
3
  notion-bridge:
4
  build: .
5
  ports:
6
+ - "7860:7860"
7
  env_file:
8
  - .env
gitignore ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Environment variables
2
+ .env
3
+
4
+ # Python artifacts
5
+ __pycache__/
6
+ *.pyc
7
+ *.pyo
8
+ *.pyd
9
+ .Python
10
+ build/
11
+ develop-eggs/
12
+ dist/
13
+ downloads/
14
+ eggs/
15
+ .eggs/
16
+ lib/
17
+ lib64/
18
+ parts/
19
+ sdist/
20
+ var/
21
+ wheels/
22
+ share/python-wheels/
23
+ *.egg-info/
24
+ .installed.cfg
25
+ *.egg
26
+ MANIFEST
27
+
28
+ # Virtual environment
29
+ .venv
30
+ venv/
31
+ ENV/
32
+ env/
main.py CHANGED
@@ -1,373 +1,835 @@
1
- import os
2
- import uuid
3
- import json
4
- import time
5
- import random
6
- import httpx
7
- from fastapi import FastAPI, Request, HTTPException, Depends, status
8
- from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
9
- from fastapi.responses import StreamingResponse
10
- from dotenv import load_dotenv
11
- import secrets # Added for secure comparison
12
- from datetime import datetime, timedelta, timezone # Explicit datetime imports
13
- from zoneinfo import ZoneInfo # For timezone handling
14
- from models import (
15
- ChatMessage, ChatCompletionRequest, NotionTranscriptConfigValue,
16
- NotionTranscriptContextValue, NotionTranscriptItem, NotionDebugOverrides,
17
- NotionRequestBody, ChoiceDelta, Choice, ChatCompletionChunk, Model, ModelList
18
- )
19
-
20
- # Load environment variables from .env file
21
- load_dotenv()
22
-
23
- # --- Configuration ---
24
- NOTION_API_URL = "https://www.notion.so/api/v3/runInferenceTranscript"
25
- # IMPORTANT: Load the Notion cookie securely from environment variables
26
- NOTION_COOKIE = os.getenv("NOTION_COOKIE")
27
-
28
- NOTION_SPACE_ID = os.getenv("NOTION_SPACE_ID")
29
- if not NOTION_COOKIE:
30
- print("Error: NOTION_COOKIE environment variable not set.")
31
- # Consider raising HTTPException or exiting in a real app
32
- if not NOTION_SPACE_ID:
33
- print("Warning: NOTION_SPACE_ID environment variable not set. Using a default UUID.")
34
- # Using a default might not be ideal, depends on Notion's behavior
35
- # Consider raising an error instead: raise ValueError("NOTION_SPACE_ID not set")
36
- NOTION_SPACE_ID = str(uuid.uuid4()) # Default or raise error
37
-
38
- # --- Authentication ---
39
- EXPECTED_TOKEN = os.getenv("PROXY_AUTH_TOKEN", "default_token") # Default token
40
- security = HTTPBearer()
41
-
42
- def authenticate(credentials: HTTPAuthorizationCredentials = Depends(security)):
43
- """Compares provided token with the expected token."""
44
- correct_token = secrets.compare_digest(credentials.credentials, EXPECTED_TOKEN)
45
- if not correct_token:
46
- raise HTTPException(
47
- status_code=status.HTTP_401_UNAUTHORIZED,
48
- detail="Invalid authentication credentials",
49
- # WWW-Authenticate header removed for Bearer
50
- )
51
- return True # Indicate successful authentication
52
-
53
- # --- FastAPI App ---
54
- app = FastAPI()
55
-
56
- # --- Helper Functions ---
57
-
58
- def build_notion_request(request_data: ChatCompletionRequest) -> NotionRequestBody:
59
- """Transforms OpenAI-style messages to Notion transcript format, adding userId and createdAt."""
60
-
61
- # --- Timestamp and User ID Logic ---
62
- user_id = os.getenv("NOTION_ACTIVE_USER_HEADER")
63
- # Get all non-assistant messages to assign timestamps
64
- non_assistant_messages = [msg for msg in request_data.messages if msg.role != "assistant"]
65
- num_non_assistant_messages = len(non_assistant_messages)
66
- message_timestamps = {} # Store timestamps keyed by message id
67
-
68
- if num_non_assistant_messages > 0:
69
- # Get current time specifically in Pacific Time (America/Los_Angeles)
70
- pacific_tz = ZoneInfo("America/Los_Angeles")
71
- now_pacific = datetime.now(timezone.utc).astimezone(pacific_tz)
72
-
73
- # Assign timestamp to the last non-assistant message
74
- last_msg_id = non_assistant_messages[-1].id
75
- message_timestamps[last_msg_id] = now_pacific
76
-
77
- # Calculate timestamps for previous non-assistant messages (random intervals earlier)
78
- current_timestamp = now_pacific
79
- for i in range(num_non_assistant_messages - 2, -1, -1): # Iterate backwards from second-to-last
80
- current_timestamp -= timedelta(minutes=random.randint(3, 20)) # Use random interval (3-20 mins)
81
- message_timestamps[non_assistant_messages[i].id] = current_timestamp
82
-
83
- # --- Build Transcript ---
84
- # Get current time in Pacific timezone for context
85
- pacific_tz = ZoneInfo("America/Los_Angeles")
86
- now_pacific = datetime.now(timezone.utc).astimezone(pacific_tz)
87
- # Format timestamp exactly as YYYY-MM-DDTHH:MM:SS.fff-HH:MM
88
- dt_str = now_pacific.strftime("%Y-%m-%dT%H:%M:%S")
89
- ms = f"{now_pacific.microsecond // 1000:03d}" # Ensure 3 digits for milliseconds
90
- tz_str = now_pacific.strftime("%z") # Gets +HHMM or -HHMM
91
- formatted_tz = f"{tz_str[:-2]}:{tz_str[-2:]}" # Insert colon
92
- current_datetime_iso = f"{dt_str}.{ms}{formatted_tz}"
93
-
94
- # Generate random text for userName and spaceName
95
- random_words = ["Project", "Workspace", "Team", "Studio", "Lab", "Hub", "Zone", "Space"]
96
- user_name = f"User{random.randint(100, 999)}"
97
- space_name = f"{random.choice(random_words)} {random.randint(1, 99)}"
98
-
99
- transcript = [
100
- NotionTranscriptItem(
101
- type="config",
102
- value=NotionTranscriptConfigValue(model=request_data.notion_model)
103
- ),
104
- NotionTranscriptItem(
105
- type="context",
106
- value=NotionTranscriptContextValue(
107
- userId=user_id or "", # Use the user_id from env or empty string
108
- spaceId=NOTION_SPACE_ID,
109
- surface="home_module",
110
- timezone="America/Los_Angeles",
111
- userName=user_name,
112
- spaceName=space_name,
113
- spaceViewId=str(uuid.uuid4()), # Random UUID for spaceViewId
114
- currentDatetime=current_datetime_iso
115
- )
116
- ),
117
- NotionTranscriptItem(
118
- type="agent-integration"
119
- # No value field needed for agent-integration
120
- )
121
- ]
122
-
123
- for message in request_data.messages:
124
- if message.role == "assistant":
125
- # Assistant messages get type="markdown-chat" and a traceId
126
- transcript.append(NotionTranscriptItem(
127
- type="markdown-chat",
128
- value=message.content,
129
- traceId=str(uuid.uuid4()) # Generate unique traceId for assistant message
130
- ))
131
- else: # Treat all other roles (user, system, etc.) as "user" type
132
- created_at_dt = message_timestamps.get(message.id) # Use the unified timestamp dict
133
- created_at_iso = None
134
- if created_at_dt:
135
- # Format timestamp exactly as YYYY-MM-DDTHH:MM:SS.fff-HH:MM
136
- dt_str = created_at_dt.strftime("%Y-%m-%dT%H:%M:%S")
137
- ms = f"{created_at_dt.microsecond // 1000:03d}" # Ensure 3 digits for milliseconds
138
- tz_str = created_at_dt.strftime("%z") # Gets +HHMM or -HHMM
139
- formatted_tz = f"{tz_str[:-2]}:{tz_str[-2:]}" # Insert colon
140
- created_at_iso = f"{dt_str}.{ms}{formatted_tz}"
141
-
142
- content = message.content
143
- # Ensure content is treated as a string for user/system messages
144
- if isinstance(content, list):
145
- # Attempt to extract text from list format, default to empty string
146
- text_content = ""
147
- for part in content:
148
- if isinstance(part, dict) and part.get("type") == "text":
149
- text_part = part.get("text")
150
- if isinstance(text_part, str):
151
- text_content += text_part # Concatenate text parts if needed
152
- content = text_content if text_content else "" # Use extracted text or empty string
153
- elif not isinstance(content, str):
154
- content = "" # Default to empty string if not list or string
155
-
156
- # Format value as expected by Notion for user type: [[content_string]]
157
- notion_value = [[content]] if content else [[""]]
158
-
159
- transcript.append(NotionTranscriptItem(
160
- type="user", # Set type to "user" for non-assistant roles
161
- value=notion_value,
162
- userId=user_id, # Assign userId
163
- createdAt=created_at_iso # Assign timestamp
164
- # No traceId for user/system messages
165
- ))
166
-
167
- # Use globally configured spaceId, set createThread=True
168
- return NotionRequestBody(
169
- spaceId=NOTION_SPACE_ID, # From environment variable
170
- transcript=transcript,
171
- createThread=True, # Always create a new thread
172
- # Generate a new traceId for each request
173
- traceId=str(uuid.uuid4()),
174
- # Explicitly set debugOverrides, generateTitle, and saveAllThreadOperations
175
- debugOverrides=NotionDebugOverrides(
176
- cachedInferences={},
177
- annotationInferences={},
178
- emitInferences=False
179
- ),
180
- generateTitle=False,
181
- saveAllThreadOperations=False
182
- )
183
-
184
- async def stream_notion_response(notion_request_body: NotionRequestBody):
185
- """Streams the request to Notion and yields OpenAI-compatible SSE chunks."""
186
- headers = {
187
- 'accept': 'application/x-ndjson',
188
- 'accept-language': 'en-US,en;q=0.9',
189
- 'content-type': 'application/json',
190
- 'notion-audit-log-platform': 'web',
191
- 'notion-client-version': '23.13.0.3668', # Consider making this configurable
192
- 'origin': 'https://www.notion.so',
193
- 'priority': 'u=1, i',
194
- # Referer might be optional or need adjustment. Removing threadId part.
195
- 'referer': 'https://www.notion.so/chat',
196
- 'sec-ch-ua': '"Chromium";v="136", "Google Chrome";v="136", "Not.A/Brand";v="99"',
197
- 'sec-ch-ua-mobile': '?0',
198
- 'sec-ch-ua-platform': '"Windows"',
199
- 'sec-fetch-dest': 'empty',
200
- 'sec-fetch-mode': 'cors',
201
- 'sec-fetch-site': 'same-origin',
202
- 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36',
203
- 'cookie': NOTION_COOKIE, # Loaded from .env
204
- 'x-notion-space-id': NOTION_SPACE_ID # Added space ID header
205
- }
206
-
207
- # Conditionally add the active user header
208
- notion_active_user = os.getenv("NOTION_ACTIVE_USER_HEADER")
209
- if notion_active_user: # Checks for None and empty string implicitly
210
- headers['x-notion-active-user-header'] = notion_active_user
211
-
212
- chunk_id = f"chatcmpl-{uuid.uuid4()}"
213
- created_time = int(time.time())
214
-
215
- try:
216
- async with httpx.AsyncClient(timeout=None) as client: # No timeout for streaming
217
- # Explicitly serialize using .json() to respect Pydantic Config (like json_encoders for UUID)
218
- request_body_json = notion_request_body.json()
219
- async with client.stream("POST", NOTION_API_URL, content=request_body_json, headers=headers) as response:
220
- if response.status_code != 200:
221
- error_content = await response.aread()
222
- print(f"Error from Notion API: {response.status_code}")
223
- print(f"Response: {error_content.decode()}")
224
- # Yield an error message in SSE format? Or just raise exception?
225
- # For now, raise internal server error in the endpoint
226
- raise HTTPException(status_code=response.status_code, detail=f"Notion API Error: {error_content.decode()}")
227
-
228
- async for line in response.aiter_lines():
229
- if not line.strip():
230
- continue
231
- try:
232
- data = json.loads(line)
233
- # Check if it's the type of message containing text chunks
234
- if data.get("type") == "markdown-chat" and isinstance(data.get("value"), str):
235
- content_chunk = data["value"]
236
- if content_chunk: # Only send if there's content
237
- chunk = ChatCompletionChunk(
238
- id=chunk_id,
239
- created=created_time,
240
- choices=[Choice(delta=ChoiceDelta(content=content_chunk))]
241
- )
242
- yield f"data: {chunk.json()}\n\n"
243
- # Add logic here to detect the end of the stream if Notion has a specific marker
244
- # For now, we assume markdown-chat stops when the main content is done.
245
- # If we see a recordMap, it's definitely past the text stream.
246
- elif "recordMap" in data:
247
- print("Detected recordMap, stopping stream.")
248
- break # Stop processing after recordMap
249
-
250
- except json.JSONDecodeError:
251
- print(f"Warning: Could not decode JSON line: {line}")
252
- except Exception as e:
253
- print(f"Error processing line: {line} - {e}")
254
- # Decide if we should continue or stop
255
-
256
- # Send the final chunk indicating stop
257
- final_chunk = ChatCompletionChunk(
258
- id=chunk_id,
259
- created=created_time,
260
- choices=[Choice(delta=ChoiceDelta(), finish_reason="stop")]
261
- )
262
- yield f"data: {final_chunk.json()}\n\n"
263
- yield "data: [DONE]\n\n"
264
-
265
- except httpx.RequestError as e:
266
- print(f"HTTPX Request Error: {e}")
267
- # Yield an error message or handle in the endpoint
268
- # For now, let the endpoint handle it
269
- raise HTTPException(status_code=500, detail=f"Error connecting to Notion API: {e}")
270
- except Exception as e:
271
- print(f"Unexpected error during streaming: {e}")
272
- # Yield an error message or handle in the endpoint
273
- raise HTTPException(status_code=500, detail=f"Internal server error during streaming: {e}")
274
-
275
-
276
- # --- API Endpoint ---
277
-
278
- @app.get("/v1/models", response_model=ModelList)
279
- async def list_models(authenticated: bool = Depends(authenticate)):
280
- """
281
- Endpoint to list available Notion models, mimicking OpenAI's /v1/models.
282
- """
283
- available_models = [
284
- "openai-gpt-4.1",
285
- "anthropic-opus-4",
286
- "anthropic-sonnet-4"
287
- ]
288
- model_list = [
289
- Model(id=model_id, owned_by="notion") # created uses default_factory
290
- for model_id in available_models
291
- ]
292
- return ModelList(data=model_list)
293
- @app.post("/v1/chat/completions")
294
- async def chat_completions(request_data: ChatCompletionRequest, request: Request, authenticated: bool = Depends(authenticate)):
295
- """
296
- Endpoint to mimic OpenAI's chat completions, proxying to Notion.
297
- """
298
- if not NOTION_COOKIE:
299
- raise HTTPException(status_code=500, detail="Server configuration error: Notion cookie not set.")
300
-
301
- notion_request_body = build_notion_request(request_data)
302
-
303
- if request_data.stream:
304
- return StreamingResponse(
305
- stream_notion_response(notion_request_body),
306
- media_type="text/event-stream"
307
- )
308
- else:
309
- # --- Non-Streaming Logic (Optional - Collects stream internally) ---
310
- # Note: The primary goal is streaming, but a non-streaming version
311
- # might be useful for testing or simpler clients.
312
- # This requires collecting all chunks from the async generator.
313
- full_response_content = ""
314
- final_finish_reason = None
315
- chunk_id = f"chatcmpl-{uuid.uuid4()}" # Generate ID for the non-streamed response
316
- created_time = int(time.time())
317
-
318
- try:
319
- async for line in stream_notion_response(notion_request_body):
320
- if line.startswith("data: ") and "[DONE]" not in line:
321
- try:
322
- data_json = line[len("data: "):].strip()
323
- if data_json:
324
- chunk_data = json.loads(data_json)
325
- if chunk_data.get("choices"):
326
- delta = chunk_data["choices"][0].get("delta", {})
327
- content = delta.get("content")
328
- if content:
329
- full_response_content += content
330
- finish_reason = chunk_data["choices"][0].get("finish_reason")
331
- if finish_reason:
332
- final_finish_reason = finish_reason
333
- except json.JSONDecodeError:
334
- print(f"Warning: Could not decode JSON line in non-streaming mode: {line}")
335
-
336
- # Construct the final OpenAI-compatible non-streaming response
337
- return {
338
- "id": chunk_id,
339
- "object": "chat.completion",
340
- "created": created_time,
341
- "model": request_data.model, # Return the model requested by the client
342
- "choices": [
343
- {
344
- "index": 0,
345
- "message": {
346
- "role": "assistant",
347
- "content": full_response_content,
348
- },
349
- "finish_reason": final_finish_reason or "stop", # Default to stop if not explicitly set
350
- }
351
- ],
352
- "usage": { # Note: Token usage is not available from Notion
353
- "prompt_tokens": None,
354
- "completion_tokens": None,
355
- "total_tokens": None,
356
- },
357
- }
358
- except HTTPException as e:
359
- # Re-raise HTTP exceptions from the streaming function
360
- raise e
361
- except Exception as e:
362
- print(f"Error during non-streaming processing: {e}")
363
- raise HTTPException(status_code=500, detail="Internal server error processing Notion response")
364
-
365
-
366
- # --- Uvicorn Runner ---
367
- # Allows running with `python main.py` for simple testing,
368
- # but `uvicorn main:app --reload` is recommended for development.
369
- if __name__ == "__main__":
370
- import uvicorn
371
- print("Starting server. Access at http://127.0.0.1:7860")
372
- print("Ensure NOTION_COOKIE is set in your .env file or environment.")
373
- uvicorn.run(app, host="127.0.0.1", port=7860)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import uuid
3
+ import json
4
+ import time
5
+ import asyncio
6
+ import random
7
+ import threading
8
+ from curl_cffi.requests import AsyncSession
9
+ from fastapi import FastAPI, Request, HTTPException, Depends, status
10
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
11
+ from fastapi.responses import StreamingResponse
12
+ from dotenv import load_dotenv
13
+ import secrets
14
+ from pydantic import BaseModel, Field
15
+ from typing import List, Optional, Dict, Any, Literal, Union
16
+ from contextlib import asynccontextmanager
17
+
18
+ # Load environment variables from .env file
19
+ load_dotenv()
20
+
21
+ # --- 并发请求配置 ---
22
+ CONCURRENT_REQUESTS = 1 # 可自定义并发请求数量
23
+
24
+ # --- 重试配置 ---
25
+ MAX_RETRIES = 3
26
+ RETRY_DELAY = 1 # 秒
27
+
28
+ # --- Models (Integrated from models.py) ---
29
+
30
+ # Input Models (OpenAI-like)
31
+ class ChatMessage(BaseModel):
32
+ role: Literal["system", "user", "assistant"]
33
+ content: str
34
+
35
+ class ChatCompletionRequest(BaseModel):
36
+ messages: List[ChatMessage]
37
+ model: str = "notion-proxy"
38
+ stream: bool = False
39
+ notion_model: str = "anthropic-opus-4"
40
+
41
+
42
+ # Notion Models
43
+ class NotionTranscriptConfigValue(BaseModel):
44
+ type: str = "markdown-chat"
45
+ model: str # e.g., "anthropic-opus-4"
46
+
47
+ class NotionTranscriptItem(BaseModel):
48
+ type: Literal["config", "user", "markdown-chat"]
49
+ value: Union[List[List[str]], str, NotionTranscriptConfigValue]
50
+
51
+ class NotionDebugOverrides(BaseModel):
52
+ cachedInferences: Dict = Field(default_factory=dict)
53
+ annotationInferences: Dict = Field(default_factory=dict)
54
+ emitInferences: bool = False
55
+
56
+ class NotionRequestBody(BaseModel):
57
+ traceId: str = Field(default_factory=lambda: str(uuid.uuid4()))
58
+ spaceId: str
59
+ transcript: List[NotionTranscriptItem]
60
+ # threadId is removed, createThread will be set to true
61
+ createThread: bool = True
62
+ debugOverrides: NotionDebugOverrides = Field(default_factory=NotionDebugOverrides)
63
+ generateTitle: bool = False
64
+ saveAllThreadOperations: bool = True
65
+
66
+
67
+ # Output Models (OpenAI SSE)
68
+ class ChoiceDelta(BaseModel):
69
+ content: Optional[str] = None
70
+
71
+ class Choice(BaseModel):
72
+ index: int = 0
73
+ delta: ChoiceDelta
74
+ finish_reason: Optional[Literal["stop", "length"]] = None
75
+
76
+ class ChatCompletionChunk(BaseModel):
77
+ id: str = Field(default_factory=lambda: f"chatcmpl-{uuid.uuid4()}")
78
+ object: str = "chat.completion.chunk"
79
+ created: int = Field(default_factory=lambda: int(time.time()))
80
+ model: str = "notion-proxy" # Or could reflect the underlying Notion model
81
+ choices: List[Choice]
82
+
83
+
84
+ # Models for /v1/models Endpoint
85
+ class Model(BaseModel):
86
+ id: str
87
+ object: str = "model"
88
+ created: int = Field(default_factory=lambda: int(time.time()))
89
+ owned_by: str = "notion" # Or specify based on actual model origin if needed
90
+
91
+ class ModelList(BaseModel):
92
+ object: str = "list"
93
+ data: List[Model]
94
+
95
+ # --- Configuration ---
96
+ NOTION_API_URL = "https://www.notion.so/api/v3/runInferenceTranscript"
97
+ # IMPORTANT: Load the Notion cookie securely from environment variables
98
+ NOTION_COOKIE = os.getenv("NOTION_COOKIE")
99
+
100
+ NOTION_SPACE_ID = os.getenv("NOTION_SPACE_ID")
101
+ if not NOTION_COOKIE:
102
+ print("Error: NOTION_COOKIE environment variable not set.")
103
+ # Consider raising HTTPException or exiting in a real app
104
+ if not NOTION_SPACE_ID:
105
+ print("Warning: NOTION_SPACE_ID environment variable not set. Using a default UUID.")
106
+ # Using a default might not be ideal, depends on Notion's behavior
107
+ # Consider raising an error instead: raise ValueError("NOTION_SPACE_ID not set")
108
+ NOTION_SPACE_ID = str(uuid.uuid4()) # Default or raise error
109
+
110
+ # --- Cookie Management ---
111
+ browser_cookies = ""
112
+ cookie_lock = threading.Lock()
113
+ last_cookie_update = 0
114
+ COOKIE_UPDATE_INTERVAL = 30 * 60 # 30 minutes in seconds
115
+
116
+ async def get_browser_cookies():
117
+ """获取Notion网站的浏览器cookie"""
118
+ global browser_cookies, last_cookie_update
119
+
120
+ try:
121
+ print("正在获取Notion浏览器cookie...")
122
+ async with AsyncSession(impersonate="chrome136") as session:
123
+ response = await session.get("https://www.notion.so")
124
+
125
+ if response.status_code == 200:
126
+ # 获取所有cookie
127
+ cookies = response.cookies
128
+ notion_so_cookies = []
129
+
130
+ # 处理CookieConflict问题,只获取.notion.so域名的cookie
131
+ try:
132
+ # 尝试通过域名过滤来避免冲突
133
+ if hasattr(cookies, 'get_dict'):
134
+ # 使用get_dict方法并指定域名
135
+ notion_so_dict = cookies.get_dict(domain='.notion.so')
136
+ for name, value in notion_so_dict.items():
137
+ notion_so_cookies.append(f"{name}={value}")
138
+ elif hasattr(cookies, 'jar'):
139
+ # 如果cookies有jar属性,遍历并过滤域名
140
+ for cookie in cookies.jar:
141
+ if hasattr(cookie, 'domain') and cookie.domain:
142
+ if '.notion.so' in cookie.domain and '.notion.com' not in cookie.domain:
143
+ notion_so_cookies.append(f"{cookie.name}={cookie.value}")
144
+ else:
145
+ # 尝试手动构建cookie字符串,避免冲突
146
+ # 直接从响应头中提取Set-Cookie信息
147
+ set_cookie_headers = response.headers.get_list('Set-Cookie') if hasattr(response.headers, 'get_list') else []
148
+ if not set_cookie_headers and 'Set-Cookie' in response.headers:
149
+ set_cookie_headers = [response.headers['Set-Cookie']]
150
+
151
+ for cookie_header in set_cookie_headers:
152
+ if 'domain=.notion.so' in cookie_header or ('notion.so' in cookie_header and 'notion.com' not in cookie_header):
153
+ # 提取cookie名称和值
154
+ cookie_parts = cookie_header.split(';')[0].strip()
155
+ if '=' in cookie_parts:
156
+ notion_so_cookies.append(cookie_parts)
157
+
158
+ # 如果还是没有获取到,尝试使用requests-like的方式
159
+ if not notion_so_cookies and hasattr(response, 'cookies'):
160
+ try:
161
+ # 遍历所有cookie,手动过滤
162
+ for cookie in response.cookies:
163
+ if hasattr(cookie, 'domain') and cookie.domain and '.notion.so' in cookie.domain:
164
+ notion_so_cookies.append(f"{cookie.name}={cookie.value}")
165
+ except Exception as inner_e:
166
+ print(f"内部cookie处理错误: {inner_e}")
167
+
168
+ except Exception as cookie_error:
169
+ print(f"处理cookie时出现错误: {cookie_error}")
170
+ # 如果所有方法都失败,尝试从session获取
171
+ if hasattr(session, 'cookies'):
172
+ try:
173
+ for name, value in session.cookies.items():
174
+ notion_so_cookies.append(f"{name}={value}")
175
+ except:
176
+ pass
177
+
178
+ # 添加环境变量中的cookie,加上token_v2前缀
179
+ if NOTION_COOKIE:
180
+ notion_so_cookies.append(f"token_v2={NOTION_COOKIE}")
181
+
182
+ # 如果没有获取到任何cookie,至少使用环境变量的
183
+ if not notion_so_cookies and NOTION_COOKIE:
184
+ notion_so_cookies = [f"token_v2={NOTION_COOKIE}"]
185
+
186
+ with cookie_lock:
187
+ browser_cookies = "; ".join(notion_so_cookies)
188
+ last_cookie_update = time.time()
189
+
190
+ # 提取cookie名称用于日志显示
191
+ cookie_names = []
192
+ for cookie_str in notion_so_cookies:
193
+ if '=' in cookie_str:
194
+ name = cookie_str.split('=')[0]
195
+ cookie_names.append(name)
196
+
197
+ print(f"成功获取到 {len(notion_so_cookies)} 个cookie")
198
+ print(f"Cookie名称列表: {', '.join(cookie_names)}")
199
+ return True
200
+ else:
201
+ print(f"获取cookie失败,HTTP状态码: {response.status_code}")
202
+ return False
203
+
204
+ except Exception as e:
205
+ print(f"获取browser cookie时出错: {e}")
206
+ print(f"错误详情: {type(e).__name__}: {str(e)}")
207
+
208
+ # 如果完全失败,至少使用环境变量的cookie
209
+ if NOTION_COOKIE:
210
+ with cookie_lock:
211
+ browser_cookies = f"token_v2={NOTION_COOKIE}"
212
+ last_cookie_update = time.time()
213
+ print("使用环境变量cookie作为备用")
214
+ return True
215
+ return False
216
+
217
+ def should_update_cookies():
218
+ """检查是否需要更新cookie"""
219
+ return time.time() - last_cookie_update > COOKIE_UPDATE_INTERVAL
220
+
221
+ async def ensure_cookies_available():
222
+ """确保cookie可用,如果需要则更新"""
223
+ global browser_cookies
224
+
225
+ if not browser_cookies or should_update_cookies():
226
+ success = await get_browser_cookies()
227
+ if not success and not browser_cookies:
228
+ # 如果获取失败且没有备用cookie,使用环境变量的cookie
229
+ if NOTION_COOKIE:
230
+ with cookie_lock:
231
+ browser_cookies = f"token_v2={NOTION_COOKIE}"
232
+ print("使用环境变量cookie作为备用")
233
+ else:
234
+ raise HTTPException(status_code=500, detail="无法获取Notion cookie")
235
+
236
+ def start_cookie_updater():
237
+ """启动cookie定时更新器"""
238
+ def cookie_updater():
239
+ loop = asyncio.new_event_loop()
240
+ asyncio.set_event_loop(loop)
241
+
242
+ while True:
243
+ try:
244
+ if should_update_cookies():
245
+ print("开始定时更新cookie...")
246
+ loop.run_until_complete(get_browser_cookies())
247
+ time.sleep(60) # 每分钟检查一次
248
+ except Exception as e:
249
+ print(f"定时更新cookie时出错: {e}")
250
+ time.sleep(60)
251
+
252
+ thread = threading.Thread(target=cookie_updater, daemon=True)
253
+ thread.start()
254
+ print("cookie定时更新器已启动")
255
+
256
+ # --- Authentication ---
257
+ EXPECTED_TOKEN = os.getenv("PROXY_AUTH_TOKEN", "default_token") # Default token
258
+ security = HTTPBearer()
259
+
260
+ def authenticate(credentials: HTTPAuthorizationCredentials = Depends(security)):
261
+ """Compares provided token with the expected token."""
262
+ correct_token = secrets.compare_digest(credentials.credentials, EXPECTED_TOKEN)
263
+ if not correct_token:
264
+ raise HTTPException(
265
+ status_code=status.HTTP_401_UNAUTHORIZED,
266
+ detail="Invalid authentication credentials",
267
+ # WWW-Authenticate header removed for Bearer
268
+ )
269
+ return True # Indicate successful authentication
270
+
271
+ # --- Lifespan Event Handler ---
272
+ @asynccontextmanager
273
+ async def lifespan(app: FastAPI):
274
+ """应用生命周期管理"""
275
+ # 启动时的初始化
276
+ print("正在初始化Notion浏览器cookie...")
277
+ await get_browser_cookies()
278
+ # 启动cookie定时更新器
279
+ start_cookie_updater()
280
+ yield
281
+ # 关闭时的清理(如果需要)
282
+
283
+ # --- FastAPI App ---
284
+ app = FastAPI(lifespan=lifespan)
285
+
286
+ # --- Helper Functions ---
287
+
288
+ def build_notion_request(request_data: ChatCompletionRequest) -> NotionRequestBody:
289
+ """Transforms OpenAI-style messages to Notion transcript format."""
290
+ transcript = [
291
+ NotionTranscriptItem(
292
+ type="config",
293
+ value=NotionTranscriptConfigValue(model=request_data.notion_model)
294
+ )
295
+ ]
296
+ for message in request_data.messages:
297
+ # Map 'assistant' role to 'markdown-chat', all others to 'user'
298
+ if message.role == "assistant":
299
+ # Notion uses "markdown-chat" for assistant replies in the transcript history
300
+ transcript.append(NotionTranscriptItem(type="markdown-chat", value=message.content))
301
+ else:
302
+ # Map user, system, and any other potential roles to 'user'
303
+ transcript.append(NotionTranscriptItem(type="user", value=[[message.content]]))
304
+
305
+ # Use globally configured spaceId, set createThread=True
306
+ return NotionRequestBody(
307
+ spaceId=NOTION_SPACE_ID, # From environment variable
308
+ transcript=transcript,
309
+ createThread=True, # Always create a new thread
310
+ # Generate a new traceId for each request
311
+ traceId=str(uuid.uuid4()),
312
+ # Explicitly set debugOverrides, generateTitle, and saveAllThreadOperations
313
+ debugOverrides=NotionDebugOverrides(
314
+ cachedInferences={},
315
+ annotationInferences={},
316
+ emitInferences=False
317
+ ),
318
+ generateTitle=False,
319
+ saveAllThreadOperations=False
320
+ )
321
+
322
+
323
+ async def check_first_response_line(session: AsyncSession, notion_request_body: NotionRequestBody, headers: dict, request_id: int):
324
+ """检查响应的第一行,判断是否为500错误"""
325
+ try:
326
+ # 当并发请求数大于1时,添加随机延迟以避免同时到达
327
+ if CONCURRENT_REQUESTS > 1:
328
+ delay = random.uniform(0, 1.0)
329
+ print(f"并发请求 {request_id} 延迟 {delay:.2f}秒")
330
+ await asyncio.sleep(delay)
331
+
332
+ # 为每个并发请求创建独立的请求体,生成新的traceId
333
+ request_body_copy = notion_request_body.model_copy()
334
+ request_body_copy.traceId = str(uuid.uuid4())
335
+
336
+ response = await session.post(
337
+ NOTION_API_URL,
338
+ json=request_body_copy.model_dump(),
339
+ headers=headers,
340
+ stream=True
341
+ )
342
+
343
+ if response.status_code != 200:
344
+ return None, response, f"HTTP {response.status_code}"
345
+
346
+ # 读取第一行来检查是否是错误
347
+ buffer = ""
348
+ async for chunk in response.aiter_content():
349
+ if isinstance(chunk, bytes):
350
+ chunk = chunk.decode('utf-8')
351
+ buffer += chunk
352
+
353
+ # 尝试解析第一个完整的JSON行
354
+ lines = buffer.split('\n')
355
+ for line in lines:
356
+ line = line.strip()
357
+ if line:
358
+ try:
359
+ data = json.loads(line)
360
+ if (data.get("type") == "error" and
361
+ data.get("message") and
362
+ "error code 500" in data.get("message", "")):
363
+ print(f"并发请求 {request_id} 检测到500错误: {data}")
364
+ return None, response, "500 error"
365
+ else:
366
+ # 正常响应,返回response和已读取的buffer
367
+ print(f"并发请求 {request_id} 响应正常")
368
+ return (response, buffer), None, None
369
+ except json.JSONDecodeError:
370
+ continue
371
+
372
+ return None, response, "No valid response"
373
+ except Exception as e:
374
+ print(f"并发请求 {request_id} 发生异常: {e}")
375
+ return None, None, str(e)
376
+
377
+ async def stream_notion_response_single(session: AsyncSession, response, initial_buffer: str, chunk_id: str, created_time: int):
378
+ """处理单个响应的流式输出"""
379
+ buffer = initial_buffer
380
+
381
+ # 首先处理已经读取的buffer中的内容
382
+ lines = buffer.split('\n')
383
+ buffer = lines[-1]
384
+
385
+ for line in lines[:-1]:
386
+ line = line.strip()
387
+ if not line:
388
+ continue
389
+
390
+ try:
391
+ data = json.loads(line)
392
+
393
+ if data.get("type") == "markdown-chat" and isinstance(data.get("value"), str):
394
+ content_chunk = data["value"]
395
+ if content_chunk:
396
+ chunk_obj = ChatCompletionChunk(
397
+ id=chunk_id,
398
+ created=created_time,
399
+ choices=[Choice(delta=ChoiceDelta(content=content_chunk))]
400
+ )
401
+ yield f"data: {chunk_obj.model_dump_json()}\n\n"
402
+ elif "recordMap" in data:
403
+ print("Detected recordMap, stopping stream.")
404
+ # 继续处理剩余的buffer
405
+ if buffer.strip():
406
+ try:
407
+ last_data = json.loads(buffer.strip())
408
+ if last_data.get("type") == "markdown-chat" and isinstance(last_data.get("value"), str):
409
+ if last_data["value"]:
410
+ last_chunk = ChatCompletionChunk(
411
+ id=chunk_id,
412
+ created=created_time,
413
+ choices=[Choice(delta=ChoiceDelta(content=last_data["value"]))]
414
+ )
415
+ yield f"data: {last_chunk.model_dump_json()}\n\n"
416
+ except:
417
+ pass
418
+ return
419
+ except json.JSONDecodeError as e:
420
+ print(f"Warning: Could not decode JSON line: {line[:100]}... Error: {str(e)}")
421
+ except Exception as e:
422
+ print(f"Error processing line: {str(e)}")
423
+
424
+ # 继续读取剩余的响应
425
+ async for chunk in response.aiter_content():
426
+ if isinstance(chunk, bytes):
427
+ chunk = chunk.decode('utf-8')
428
+
429
+ buffer += chunk
430
+
431
+ lines = buffer.split('\n')
432
+ buffer = lines[-1]
433
+
434
+ for line in lines[:-1]:
435
+ line = line.strip()
436
+ if not line:
437
+ continue
438
+
439
+ try:
440
+ data = json.loads(line)
441
+
442
+ if data.get("type") == "markdown-chat" and isinstance(data.get("value"), str):
443
+ content_chunk = data["value"]
444
+ if content_chunk:
445
+ chunk_obj = ChatCompletionChunk(
446
+ id=chunk_id,
447
+ created=created_time,
448
+ choices=[Choice(delta=ChoiceDelta(content=content_chunk))]
449
+ )
450
+ yield f"data: {chunk_obj.model_dump_json()}\n\n"
451
+ elif "recordMap" in data:
452
+ print("Detected recordMap, stopping stream.")
453
+ if buffer.strip():
454
+ try:
455
+ last_data = json.loads(buffer.strip())
456
+ if last_data.get("type") == "markdown-chat" and isinstance(last_data.get("value"), str):
457
+ if last_data["value"]:
458
+ last_chunk = ChatCompletionChunk(
459
+ id=chunk_id,
460
+ created=created_time,
461
+ choices=[Choice(delta=ChoiceDelta(content=last_data["value"]))]
462
+ )
463
+ yield f"data: {last_chunk.model_dump_json()}\n\n"
464
+ except:
465
+ pass
466
+ return
467
+ except json.JSONDecodeError as e:
468
+ print(f"Warning: Could not decode JSON line: {line[:100]}... Error: {str(e)}")
469
+ except Exception as e:
470
+ print(f"Error processing line: {str(e)}")
471
+
472
+ async def stream_notion_response(notion_request_body: NotionRequestBody):
473
+ """Streams the request to Notion and yields OpenAI-compatible SSE chunks."""
474
+
475
+ # 确保cookie可用
476
+ await ensure_cookies_available()
477
+
478
+ with cookie_lock:
479
+ current_cookies = browser_cookies
480
+
481
+ headers = {
482
+ 'accept': 'application/x-ndjson',
483
+ 'accept-encoding': 'gzip, deflate, br, zstd',
484
+ 'accept-language': 'en-US,zh;q=0.9',
485
+ 'content-type': 'application/json',
486
+ 'dnt': '1',
487
+ 'notion-audit-log-platform': 'web',
488
+ 'notion-client-version': '23.13.0.3661',
489
+ 'origin': 'https://www.notion.so',
490
+ 'referer': 'https://www.notion.so/',
491
+ 'priority': 'u=1, i',
492
+ 'sec-ch-ua-mobile': '?0',
493
+ 'sec-ch-ua-platform': '"Windows"',
494
+ 'sec-fetch-dest': 'empty',
495
+ 'sec-fetch-mode': 'cors',
496
+ 'sec-fetch-site': 'same-origin',
497
+ 'cookie': current_cookies,
498
+ 'x-notion-space-id': NOTION_SPACE_ID
499
+ }
500
+
501
+ # Conditionally add the active user header
502
+ notion_active_user = os.getenv("NOTION_ACTIVE_USER_HEADER")
503
+ if notion_active_user: # Checks for None and empty string implicitly
504
+ headers['x-notion-active-user-header'] = notion_active_user
505
+
506
+ chunk_id = f"chatcmpl-{uuid.uuid4()}"
507
+ created_time = int(time.time())
508
+
509
+ # 使用全局重试配置
510
+ max_retries = MAX_RETRIES
511
+ retry_delay = RETRY_DELAY
512
+
513
+ # 首先尝试并发请求
514
+ print(f"同时发起 {CONCURRENT_REQUESTS} 个并发请求...")
515
+ async with AsyncSession(impersonate="chrome136") as session:
516
+ # 同时创建并发任务(每个都是独立的异步任务)
517
+ tasks = []
518
+ for i in range(CONCURRENT_REQUESTS):
519
+ task = asyncio.create_task(
520
+ check_first_response_line(session, notion_request_body, headers, i + 1)
521
+ )
522
+ tasks.append(task)
523
+
524
+ # 等待所有任务完成或找到第一个成功的响应
525
+ successful_response = None
526
+ failed_count = 0
527
+ completed_tasks = set()
528
+
529
+ while len(completed_tasks) < CONCURRENT_REQUESTS and not successful_response:
530
+ # 等待任意一个任务完成
531
+ done, pending = await asyncio.wait(
532
+ [t for t in tasks if t not in completed_tasks],
533
+ return_when=asyncio.FIRST_COMPLETED
534
+ )
535
+
536
+ for task in done:
537
+ completed_tasks.add(task)
538
+ result, response, error = await task
539
+ if result:
540
+ # 找到成功的响应,立即使用
541
+ successful_response = result
542
+ print(f"找到成功的并发响应,立即使用")
543
+ # 取消其他还在运行的任务
544
+ for t in tasks:
545
+ if t not in completed_tasks:
546
+ t.cancel()
547
+ break
548
+ else:
549
+ # 记录失败
550
+ failed_count += 1
551
+ if error:
552
+ print(f"并发请求失败: {error}")
553
+
554
+ # 如果有成功的响应,使用它进行流式传输
555
+ if successful_response:
556
+ response, initial_buffer = successful_response
557
+ print("使用成功的并发响应进行流式传输")
558
+
559
+ # 流式输出响应
560
+ async for data in stream_notion_response_single(session, response, initial_buffer, chunk_id, created_time):
561
+ yield data
562
+
563
+ # Send the final chunk indicating stop
564
+ final_chunk = ChatCompletionChunk(
565
+ id=chunk_id,
566
+ created=created_time,
567
+ choices=[Choice(delta=ChoiceDelta(), finish_reason="stop")]
568
+ )
569
+ yield f"data: {final_chunk.model_dump_json()}\n\n"
570
+ yield "data: [DONE]\n\n"
571
+ return
572
+
573
+ # 只有当所有并发请求都失败时,才进入重试流程
574
+ print(f"所有 {CONCURRENT_REQUESTS} 个并发请求都失败,开始单请求重试流程...")
575
+
576
+ # 进入原有的重试逻辑(不使用并发)
577
+ for attempt in range(max_retries):
578
+ try:
579
+ # Using curl_cffi with chrome136 impersonation for better anti-bot bypass
580
+ async with AsyncSession(impersonate="chrome136") as session:
581
+ # Stream the response
582
+ response = await session.post(
583
+ NOTION_API_URL,
584
+ json=notion_request_body.model_dump(),
585
+ headers=headers,
586
+ stream=True
587
+ )
588
+
589
+ if response.status_code != 200:
590
+ error_content = await response.atext()
591
+ print(f"Error from Notion API: {response.status_code}")
592
+ print(f"Response: {error_content}")
593
+ raise HTTPException(status_code=response.status_code, detail=f"Notion API Error: {error_content}")
594
+
595
+ # Process streaming response
596
+ # curl_cffi streaming works differently - we need to read the content in chunks
597
+ buffer = ""
598
+ first_line_checked = False
599
+ is_error_response = False
600
+
601
+ async for chunk in response.aiter_content():
602
+ # Decode chunk if it's bytes
603
+ if isinstance(chunk, bytes):
604
+ chunk = chunk.decode('utf-8')
605
+
606
+ buffer += chunk
607
+
608
+ # Split by newlines and process complete lines
609
+ lines = buffer.split('\n')
610
+ # Keep the last incomplete line in the buffer
611
+ buffer = lines[-1]
612
+
613
+ for line in lines[:-1]:
614
+ line = line.strip()
615
+ if not line:
616
+ continue
617
+
618
+ try:
619
+ data = json.loads(line)
620
+
621
+ # 检查第一行是否是500错误响应
622
+ if not first_line_checked:
623
+ first_line_checked = True
624
+ if (data.get("type") == "error" and
625
+ data.get("message") and
626
+ "error code 500" in data.get("message", "")):
627
+ print(f"检测到Notion API 500错误 (重试 {attempt + 1}/{max_retries}): {data}")
628
+ is_error_response = True
629
+ break
630
+
631
+ # 如果不是错误响应,实时流式转发
632
+ # Check if it's the type of message containing text chunks
633
+ if data.get("type") == "markdown-chat" and isinstance(data.get("value"), str):
634
+ content_chunk = data["value"]
635
+ if content_chunk: # Only send if there's content
636
+ chunk_obj = ChatCompletionChunk(
637
+ id=chunk_id,
638
+ created=created_time,
639
+ choices=[Choice(delta=ChoiceDelta(content=content_chunk))]
640
+ )
641
+ yield f"data: {chunk_obj.model_dump_json()}\n\n"
642
+ # Add logic here to detect the end of the stream if Notion has a specific marker
643
+ # For now, we assume markdown-chat stops when the main content is done.
644
+ # If we see a recordMap, it's definitely past the text stream.
645
+ elif "recordMap" in data:
646
+ print("Detected recordMap, stopping stream.")
647
+ # Process any remaining buffer
648
+ if buffer.strip():
649
+ try:
650
+ last_data = json.loads(buffer.strip())
651
+ if last_data.get("type") == "markdown-chat" and isinstance(last_data.get("value"), str):
652
+ if last_data["value"]:
653
+ last_chunk = ChatCompletionChunk(
654
+ id=chunk_id,
655
+ created=created_time,
656
+ choices=[Choice(delta=ChoiceDelta(content=last_data["value"]))]
657
+ )
658
+ yield f"data: {last_chunk.model_dump_json()}\n\n"
659
+ except:
660
+ pass
661
+ # Exit the loop
662
+ break
663
+
664
+ except json.JSONDecodeError as e:
665
+ print(f"Warning: Could not decode JSON line: {line[:100]}... Error: {str(e)}")
666
+ except Exception as e:
667
+ print(f"Error processing line: {str(e)}")
668
+ # Continue processing other lines
669
+
670
+ if is_error_response:
671
+ break
672
+
673
+ # 如果检测到错误,进行重试
674
+ if is_error_response:
675
+ if attempt < max_retries - 1:
676
+ print(f"等待 {retry_delay} 秒后重试...")
677
+ await asyncio.sleep(retry_delay)
678
+ continue # 重试
679
+ else:
680
+ # 所有重试都失败了,通过流式响应返回错误信息
681
+ print("所有重试都失败,返回500错误给客户端")
682
+ error_chunk = ChatCompletionChunk(
683
+ id=chunk_id,
684
+ created=created_time,
685
+ choices=[Choice(delta=ChoiceDelta(content="Error: Notion API returned error code 500 after all retries"), finish_reason="stop")]
686
+ )
687
+ yield f"data: {error_chunk.model_dump_json()}\n\n"
688
+ yield "data: [DONE]\n\n"
689
+ return
690
+
691
+ # 如果没有错误,发送最终的停止信号
692
+ # Send the final chunk indicating stop
693
+ final_chunk = ChatCompletionChunk(
694
+ id=chunk_id,
695
+ created=created_time,
696
+ choices=[Choice(delta=ChoiceDelta(), finish_reason="stop")]
697
+ )
698
+ yield f"data: {final_chunk.model_dump_json()}\n\n"
699
+ yield "data: [DONE]\n\n"
700
+
701
+ # 成功完成,退出重试循环
702
+ break
703
+
704
+ except HTTPException:
705
+ # 在流式响应中不能抛出HTTPException,通过流式响应返回错误
706
+ if attempt < max_retries - 1:
707
+ print(f"HTTP异常,等待 {retry_delay} 秒后重试...")
708
+ await asyncio.sleep(retry_delay)
709
+ continue
710
+ else:
711
+ print("HTTP异常且无更多重试,返回错误信息")
712
+ error_chunk = ChatCompletionChunk(
713
+ id=chunk_id,
714
+ created=created_time,
715
+ choices=[Choice(delta=ChoiceDelta(content="Error: HTTP exception occurred after all retries"), finish_reason="stop")]
716
+ )
717
+ yield f"data: {error_chunk.model_dump_json()}\n\n"
718
+ yield "data: [DONE]\n\n"
719
+ return
720
+ except Exception as e:
721
+ print(f"Unexpected error during streaming (attempt {attempt + 1}/{max_retries}): {e}")
722
+ if attempt < max_retries - 1:
723
+ print(f"等待 {retry_delay} 秒后重试...")
724
+ await asyncio.sleep(retry_delay)
725
+ continue
726
+ else:
727
+ print("意外错误且无更多重试,返回错误信息")
728
+ error_chunk = ChatCompletionChunk(
729
+ id=chunk_id,
730
+ created=created_time,
731
+ choices=[Choice(delta=ChoiceDelta(content=f"Error: Internal server error during streaming: {e}"), finish_reason="stop")]
732
+ )
733
+ yield f"data: {error_chunk.model_dump_json()}\n\n"
734
+ yield "data: [DONE]\n\n"
735
+ return
736
+
737
+
738
+ # --- API Endpoints ---
739
+
740
+ @app.get("/v1/models", response_model=ModelList)
741
+ async def list_models(authenticated: bool = Depends(authenticate)):
742
+ """
743
+ Endpoint to list available Notion models, mimicking OpenAI's /v1/models.
744
+ """
745
+ available_models = [
746
+ "openai-gpt-4.1",
747
+ "anthropic-opus-4",
748
+ "anthropic-sonnet-4"
749
+ ]
750
+ model_list = [
751
+ Model(id=model_id, owned_by="notion") # created uses default_factory
752
+ for model_id in available_models
753
+ ]
754
+ return ModelList(data=model_list)
755
+
756
+ @app.post("/v1/chat/completions")
757
+ async def chat_completions(request_data: ChatCompletionRequest, request: Request, authenticated: bool = Depends(authenticate)):
758
+ """
759
+ Endpoint to mimic OpenAI's chat completions, proxying to Notion.
760
+ """
761
+ if not NOTION_COOKIE:
762
+ raise HTTPException(status_code=500, detail="Server configuration error: Notion cookie not set.")
763
+
764
+ notion_request_body = build_notion_request(request_data)
765
+
766
+ if request_data.stream:
767
+ return StreamingResponse(
768
+ stream_notion_response(notion_request_body),
769
+ media_type="text/event-stream"
770
+ )
771
+ else:
772
+ # --- Non-Streaming Logic (Optional - Collects stream internally) ---
773
+ # Note: The primary goal is streaming, but a non-streaming version
774
+ # might be useful for testing or simpler clients.
775
+ # This requires collecting all chunks from the async generator.
776
+ full_response_content = ""
777
+ final_finish_reason = None
778
+ chunk_id = f"chatcmpl-{uuid.uuid4()}" # Generate ID for the non-streamed response
779
+ created_time = int(time.time())
780
+
781
+ try:
782
+ async for line in stream_notion_response(notion_request_body):
783
+ if line.startswith("data: ") and "[DONE]" not in line:
784
+ try:
785
+ data_json = line[len("data: "):].strip()
786
+ if data_json:
787
+ chunk_data = json.loads(data_json)
788
+ if chunk_data.get("choices"):
789
+ delta = chunk_data["choices"][0].get("delta", {})
790
+ content = delta.get("content")
791
+ if content:
792
+ full_response_content += content
793
+ finish_reason = chunk_data["choices"][0].get("finish_reason")
794
+ if finish_reason:
795
+ final_finish_reason = finish_reason
796
+ except json.JSONDecodeError:
797
+ print(f"Warning: Could not decode JSON line in non-streaming mode: {line}")
798
+
799
+ # Construct the final OpenAI-compatible non-streaming response
800
+ return {
801
+ "id": chunk_id,
802
+ "object": "chat.completion",
803
+ "created": created_time,
804
+ "model": request_data.model, # Return the model requested by the client
805
+ "choices": [
806
+ {
807
+ "index": 0,
808
+ "message": {
809
+ "role": "assistant",
810
+ "content": full_response_content,
811
+ },
812
+ "finish_reason": final_finish_reason or "stop", # Default to stop if not explicitly set
813
+ }
814
+ ],
815
+ "usage": { # Note: Token usage is not available from Notion
816
+ "prompt_tokens": None,
817
+ "completion_tokens": None,
818
+ "total_tokens": None,
819
+ },
820
+ }
821
+ except HTTPException as e:
822
+ # Re-raise HTTP exceptions from the streaming function
823
+ raise e
824
+ except Exception as e:
825
+ print(f"Error during non-streaming processing: {e}")
826
+ raise HTTPException(status_code=500, detail="Internal server error processing Notion response")
827
+
828
+ if __name__ == "__main__":
829
+ import uvicorn
830
+ print("Starting server. Access at http://localhost:7860")
831
+ print("Ensure NOTION_COOKIE is set in your .env file or environment.")
832
+ print("Cookie管理系统已启用,将自动获取和更新Notion浏览器cookie")
833
+
834
+ # 运行服务器
835
+ uvicorn.run(app, host="0.0.0.0", port=7860)
models.py CHANGED
@@ -7,12 +7,8 @@ from typing import List, Optional, Dict, Any, Literal, Union
7
 
8
  # Input Models (OpenAI-like)
9
  class ChatMessage(BaseModel):
10
- id: uuid.UUID = Field(default_factory=uuid.uuid4)
11
  role: Literal["system", "user", "assistant"]
12
- content: Union[str, List[Dict[str, Any]]]
13
- userId: Optional[str] = None # Added for user messages
14
- createdAt: Optional[str] = None # Added for timestamping
15
- traceId: Optional[str] = None # Added for assistant messages
16
 
17
  class ChatCompletionRequest(BaseModel):
18
  messages: List[ChatMessage]
@@ -30,23 +26,9 @@ class NotionTranscriptConfigValue(BaseModel):
30
  type: str = "markdown-chat"
31
  model: str # e.g., "anthropic-opus-4"
32
 
33
- class NotionTranscriptContextValue(BaseModel):
34
- userId: str
35
- spaceId: str
36
- surface: str = "home_module"
37
- timezone: str = "America/Los_Angeles"
38
- userName: str
39
- spaceName: str
40
- spaceViewId: str
41
- currentDatetime: str
42
-
43
  class NotionTranscriptItem(BaseModel):
44
- id: uuid.UUID = Field(default_factory=uuid.uuid4)
45
- type: Literal["config", "user", "markdown-chat", "agent-integration", "context"]
46
- value: Optional[Union[List[List[str]], str, NotionTranscriptConfigValue, NotionTranscriptContextValue]] = None
47
- userId: Optional[str] = None # Added for user messages in Notion transcript
48
- createdAt: Optional[str] = None # Added for timestamping in Notion transcript
49
- traceId: Optional[str] = None # Added for assistant messages in Notion transcript
50
 
51
  class NotionDebugOverrides(BaseModel):
52
  cachedInferences: Dict = Field(default_factory=dict)
@@ -63,12 +45,6 @@ class NotionRequestBody(BaseModel):
63
  generateTitle: bool = False
64
  saveAllThreadOperations: bool = True
65
 
66
- class Config:
67
- # Ensure UUIDs are serialized as strings in the final JSON request
68
- json_encoders = {
69
- uuid.UUID: str
70
- }
71
-
72
 
73
  # Output Models (OpenAI SSE)
74
  class ChoiceDelta(BaseModel):
 
7
 
8
  # Input Models (OpenAI-like)
9
  class ChatMessage(BaseModel):
 
10
  role: Literal["system", "user", "assistant"]
11
+ content: str
 
 
 
12
 
13
  class ChatCompletionRequest(BaseModel):
14
  messages: List[ChatMessage]
 
26
  type: str = "markdown-chat"
27
  model: str # e.g., "anthropic-opus-4"
28
 
 
 
 
 
 
 
 
 
 
 
29
  class NotionTranscriptItem(BaseModel):
30
+ type: Literal["config", "user", "markdown-chat"]
31
+ value: Union[List[List[str]], str, NotionTranscriptConfigValue]
 
 
 
 
32
 
33
  class NotionDebugOverrides(BaseModel):
34
  cachedInferences: Dict = Field(default_factory=dict)
 
45
  generateTitle: bool = False
46
  saveAllThreadOperations: bool = True
47
 
 
 
 
 
 
 
48
 
49
  # Output Models (OpenAI SSE)
50
  class ChoiceDelta(BaseModel):
requirements.txt CHANGED
@@ -1,5 +1,5 @@
1
- fastapi
2
- uvicorn[standard]
3
- httpx
4
- pydantic
5
- python-dotenv
 
1
+ fastapi
2
+ uvicorn[standard]
3
+ curl-cffi
4
+ pydantic
5
+ python-dotenv