Skip to content

Commit 988f278

Browse files
authored
Merge pull request #25 from a8851625/agent/miniclaw-claude/1dc8c2c3
Fix account-password auth flow and streaming delivery
2 parents c524270 + 277bae4 commit 988f278

5 files changed

Lines changed: 306 additions & 140 deletions

File tree

backend/api/admin.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -124,21 +124,31 @@ async def add_account(request: Request):
124124
except Exception:
125125
raise HTTPException(400, detail="Invalid JSON body")
126126

127-
token = data.get("token", "")
128-
if not token:
129-
raise HTTPException(400, detail="token is required")
127+
email = str(data.get("email", "") or "").strip() or f"manual_{int(time.time())}@qwen"
128+
password = str(data.get("password", "") or "").strip()
129+
token = str(data.get("token", "") or "").strip()
130130

131131
acc = Account(
132-
email=data.get("email", f"manual_{int(time.time())}@qwen"),
133-
password=data.get("password", ""),
132+
email=email,
133+
password=password,
134134
token=token,
135135
cookies=data.get("cookies", ""),
136136
username=data.get("username", "")
137137
)
138138

139-
is_valid = await client.verify_token(token)
140-
if not is_valid:
141-
return {"ok": False, "error": "Invalid token (验证失败,请确认Token有效)"}
139+
# 兼容两种接入方式:
140+
# 1) 直接传 token
141+
# 2) 仅传 email+password,由服务端自动登录并获取 token
142+
if acc.token:
143+
is_valid = await client.verify_token(acc.token)
144+
if not is_valid:
145+
return {"ok": False, "error": "Invalid token (验证失败,请确认Token有效)"}
146+
else:
147+
if not acc.email or not acc.password:
148+
raise HTTPException(400, detail="token or email+password is required")
149+
refreshed = await client.auth_resolver.refresh_token(acc)
150+
if not refreshed or not acc.token:
151+
return {"ok": False, "error": "Email/password login failed (账号密码登录失败)"}
142152

143153
await pool.add(acc)
144154
return {"ok": True, "email": acc.email}

backend/api/v1_chat.py

Lines changed: 77 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from fastapi import APIRouter, HTTPException, Request
22
from fastapi.responses import JSONResponse, StreamingResponse
3+
import asyncio
34
import json
45
import logging
56
import time
@@ -125,63 +126,82 @@ async def chat_completions(request: Request):
125126

126127
if standard_request.stream:
127128
async def generate():
128-
async with app.state.session_locks.hold(session_key):
129-
try:
130-
update_request_context(stream_attempt=1)
131-
translator = OpenAIStreamTranslator(
132-
completion_id=completion_id,
133-
created=created,
134-
model_name=model_name,
135-
client_profile=standard_request.client_profile,
136-
build_final_directive=lambda answer_text: build_tool_directive(
137-
standard_request,
138-
RuntimeAttemptState(answer_text=answer_text),
139-
),
140-
allowed_tool_names=standard_request.tool_names,
141-
)
142-
143-
async def on_delta(evt: dict[str, Any], text_chunk: str | None, tool_calls: list[dict[str, Any]] | None) -> None:
144-
translator.on_delta(evt, text_chunk, tool_calls)
145-
146-
result = await run_retryable_completion_bridge(
147-
client=client,
148-
standard_request=standard_request,
149-
prompt=prompt,
150-
users_db=users_db,
151-
token=token,
152-
history_messages=history_messages,
153-
max_attempts=request_max_attempts(standard_request),
154-
usage_delta_factory=build_usage_delta_factory(prompt),
155-
allow_after_visible_output=True,
156-
capture_events=False,
157-
on_delta=on_delta,
158-
)
159-
execution = result.execution
160-
directive = result.directive or build_tool_directive(standard_request, execution.state)
161-
assistant_message = build_openai_assistant_history_message(
162-
execution=execution,
163-
request=standard_request,
164-
directive=directive,
165-
)
166-
await persist_session_turn(
167-
app=app,
168-
request=standard_request,
169-
surface="openai",
170-
execution=execution,
171-
assistant_message=assistant_message,
172-
)
173-
final_finish_reason = "tool_calls" if directive.stop_reason == "tool_use" else execution.state.finish_reason
174-
for chunk in translator.finalize(final_finish_reason):
175-
yield chunk
176-
return
177-
except HTTPException as he:
178-
await clear_invalidated_session_chat(app=app, request=standard_request)
179-
yield f"data: {json.dumps({'error': he.detail})}\n\n"
180-
return
181-
except Exception as e:
182-
await clear_invalidated_session_chat(app=app, request=standard_request)
183-
yield f"data: {json.dumps({'error': str(e)})}\n\n"
184-
return
129+
queue: asyncio.Queue[str | None] = asyncio.Queue()
130+
131+
async def producer() -> None:
132+
async with app.state.session_locks.hold(session_key):
133+
try:
134+
update_request_context(stream_attempt=1)
135+
translator = OpenAIStreamTranslator(
136+
completion_id=completion_id,
137+
created=created,
138+
model_name=model_name,
139+
client_profile=standard_request.client_profile,
140+
build_final_directive=lambda answer_text: build_tool_directive(
141+
standard_request,
142+
RuntimeAttemptState(answer_text=answer_text),
143+
),
144+
allowed_tool_names=standard_request.tool_names,
145+
)
146+
147+
async def on_delta(evt: dict[str, Any], text_chunk: str | None, tool_calls: list[dict[str, Any]] | None) -> None:
148+
translator.on_delta(evt, text_chunk, tool_calls)
149+
while translator.pending_chunks:
150+
await queue.put(translator.pending_chunks.pop(0))
151+
152+
result = await run_retryable_completion_bridge(
153+
client=client,
154+
standard_request=standard_request,
155+
prompt=prompt,
156+
users_db=users_db,
157+
token=token,
158+
history_messages=history_messages,
159+
max_attempts=request_max_attempts(standard_request),
160+
usage_delta_factory=build_usage_delta_factory(prompt),
161+
allow_after_visible_output=True,
162+
capture_events=False,
163+
on_delta=on_delta,
164+
)
165+
execution = result.execution
166+
directive = result.directive or build_tool_directive(standard_request, execution.state)
167+
assistant_message = build_openai_assistant_history_message(
168+
execution=execution,
169+
request=standard_request,
170+
directive=directive,
171+
)
172+
await persist_session_turn(
173+
app=app,
174+
request=standard_request,
175+
surface="openai",
176+
execution=execution,
177+
assistant_message=assistant_message,
178+
)
179+
final_finish_reason = "tool_calls" if directive.stop_reason == "tool_use" else (execution.state.finish_reason or "stop")
180+
for chunk in translator.finalize(final_finish_reason):
181+
await queue.put(chunk)
182+
except HTTPException as he:
183+
await clear_invalidated_session_chat(app=app, request=standard_request)
184+
await queue.put(f"data: {json.dumps({'error': he.detail})}\n\n")
185+
except Exception as e:
186+
await clear_invalidated_session_chat(app=app, request=standard_request)
187+
await queue.put(f"data: {json.dumps({'error': str(e)})}\n\n")
188+
finally:
189+
await queue.put(None)
190+
191+
producer_task = asyncio.create_task(producer())
192+
try:
193+
while True:
194+
chunk = await queue.get()
195+
if chunk is None:
196+
break
197+
yield chunk
198+
finally:
199+
if not producer_task.done():
200+
producer_task.cancel()
201+
try:
202+
await producer_task
203+
except Exception:
204+
pass
185205

186206
return StreamingResponse(
187207
generate(),

backend/services/auth_resolver.py

Lines changed: 79 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import hashlib
23
import html as html_lib
34
import json
45
import logging
@@ -736,6 +737,10 @@ class AuthResolver:
736737
def __init__(self, pool: AccountPool):
737738
self.pool = pool
738739

740+
@staticmethod
741+
def _sha256_password(password: str) -> str:
742+
return hashlib.sha256((password or "").encode("utf-8")).hexdigest()
743+
739744
async def auto_heal_account(self, acc: Account):
740745
"""Background task to refresh token. If successful, marks account valid.
741746
If refresh fails or account is pending activation, tries to activate via email."""
@@ -775,27 +780,80 @@ async def refresh_token(self, acc: Account) -> bool:
775780
if not acc.email or not acc.password:
776781
log.warning(f"[Refresh] 账号 {acc.email} 无密码,无法刷新")
777782
return False
778-
783+
779784
log.info(f"[Refresh] 正在为 {acc.email} 刷新 token...")
780785
try:
781-
async with _new_browser() as browser:
782-
page = await browser.new_page()
783-
new_token = await _login_and_get_token(page, acc.email, acc.password, timeout_sec=20)
784-
if new_token and new_token != acc.token:
785-
old_prefix = acc.token[:20] if acc.token else "空"
786-
acc.token = new_token
787-
acc.valid = True
788-
await self.pool.save()
789-
log.info(f"[Refresh] {acc.email} token 已更新 ({old_prefix}... → {new_token[:20]}...)")
790-
return True
791-
elif new_token == acc.token:
792-
# Token same but might still be valid — mark valid again
793-
acc.valid = True
794-
log.info(f"[Refresh] {acc.email} token 未变化,重新标记有效")
795-
return True
796-
else:
797-
log.warning(f"[Refresh] {acc.email} 登录后未获取到token,URL={page.url}")
798-
return False
786+
import httpx
787+
788+
payload = {
789+
"email": acc.email,
790+
"password": self._sha256_password(acc.password),
791+
}
792+
headers = {
793+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
794+
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
795+
"Accept": "application/json, text/plain, */*",
796+
"Content-Type": "application/json",
797+
"Referer": f"{BASE_URL}/",
798+
"Origin": BASE_URL,
799+
}
800+
801+
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as hc:
802+
resp = await hc.post(f"{BASE_URL}/api/v1/auths/signin", json=payload, headers=headers)
803+
804+
if resp.status_code != 200:
805+
log.warning(f"[Refresh] {acc.email} HTTP {resp.status_code},登录失败")
806+
return False
807+
808+
try:
809+
data = resp.json()
810+
except Exception:
811+
log.warning(f"[Refresh] {acc.email} 登录响应不是 JSON")
812+
return False
813+
814+
new_token = str(data.get("token", "") or "").strip()
815+
if not new_token:
816+
log.warning(f"[Refresh] {acc.email} 登录响应缺少 token 字段")
817+
return False
818+
819+
old_prefix = acc.token[:20] if acc.token else "空"
820+
acc.token = new_token
821+
acc.valid = True
822+
acc.activation_pending = False
823+
acc.status_code = "valid"
824+
acc.last_error = ""
825+
await self.pool.save()
826+
log.info(f"[Refresh] {acc.email} token 已更新 ({old_prefix}... → {new_token[:20]}...)")
827+
return True
828+
799829
except Exception as e:
800-
log.error(f"[Refresh] {acc.email} 刷新异常: {e}")
801-
return False
830+
log.warning(f"[Refresh] {acc.email} 直连登录失败,回退浏览器流程: {e}")
831+
try:
832+
async with _new_browser() as browser:
833+
page = await browser.new_page()
834+
new_token = await _login_and_get_token(page, acc.email, acc.password, timeout_sec=20)
835+
if new_token and new_token != acc.token:
836+
old_prefix = acc.token[:20] if acc.token else "空"
837+
acc.token = new_token
838+
acc.valid = True
839+
acc.activation_pending = False
840+
acc.status_code = "valid"
841+
acc.last_error = ""
842+
await self.pool.save()
843+
log.info(f"[Refresh] {acc.email} token 已更新 ({old_prefix}... → {new_token[:20]}...)")
844+
return True
845+
elif new_token == acc.token:
846+
# Token same but might still be valid — mark valid again
847+
acc.valid = True
848+
acc.activation_pending = False
849+
if acc.status_code in ("invalid", "auth_error", "pending_activation"):
850+
acc.status_code = "valid"
851+
acc.last_error = ""
852+
log.info(f"[Refresh] {acc.email} token 未变化,重新标记有效")
853+
return True
854+
else:
855+
log.warning(f"[Refresh] {acc.email} 登录后未获取到token,URL={page.url}")
856+
return False
857+
except Exception as browser_err:
858+
log.error(f"[Refresh] {acc.email} 刷新异常: {browser_err}")
859+
return False

0 commit comments

Comments
 (0)