ayb-bh92 commited on
Commit
9ab7ab8
·
verified ·
1 Parent(s): 664d850

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +1916 -550
app.py CHANGED
@@ -5,7 +5,6 @@ import uuid
5
  import logging
6
  import asyncio
7
  import hashlib
8
- import re
9
  import sqlite3
10
  from enum import Enum
11
  from concurrent.futures import ThreadPoolExecutor
@@ -15,28 +14,51 @@ import g4f
15
  from fastapi import FastAPI, HTTPException, Request
16
  from fastapi.responses import StreamingResponse, JSONResponse
17
  from pydantic import BaseModel, Field
18
- from jsonschema import Draft202012Validator
19
 
20
  logging.basicConfig(
21
- level=logging.INFO,
22
- format="%(asctime)s [%(levelname)s] %(message)s"
23
  )
24
- logger = logging.getLogger("g4f-qwen-agent")
25
 
26
- _POOL = ThreadPoolExecutor(max_workers=8)
 
 
 
 
 
 
27
 
28
  API_KEY = os.getenv("API_KEY", "mysecretkey123")
29
  DB_PATH = os.getenv("DB_PATH", "memory.db")
30
- MAX_TOOL_RETRIES = int(os.getenv("MAX_TOOL_RETRIES", "2"))
 
31
  MEMORY_LIMIT = int(os.getenv("MEMORY_LIMIT", "100"))
 
32
 
33
  # =====================================================
34
  # SQLITE MEMORY
35
  # =====================================================
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  def init_db():
38
- with sqlite3.connect(DB_PATH) as db:
39
- db.execute("""
 
40
  CREATE TABLE IF NOT EXISTS messages (
41
  id INTEGER PRIMARY KEY AUTOINCREMENT,
42
  conversation_id TEXT NOT NULL,
@@ -45,88 +67,204 @@ def init_db():
45
  tool_call_id TEXT,
46
  name TEXT,
47
  tool_calls TEXT,
48
- created_at REAL NOT NULL
 
 
49
  )
50
- """)
51
- db.execute("""
 
 
 
52
  CREATE INDEX IF NOT EXISTS idx_messages_conversation
53
  ON messages(conversation_id, id)
54
- """)
 
 
55
  db.commit()
56
 
57
 
58
- def save_message(conversation_id: str, message: Dict[str, Any]):
59
- with sqlite3.connect(DB_PATH) as db:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  db.execute(
61
  """
62
- INSERT INTO messages (
63
  conversation_id,
64
  role,
65
  content,
66
  tool_call_id,
67
  name,
68
  tool_calls,
 
69
  created_at
70
  )
71
- VALUES (?, ?, ?, ?, ?, ?, ?)
72
  """,
73
  (
74
  conversation_id,
75
- message.get("role", "user"),
76
- json.dumps(message.get("content"), ensure_ascii=False)
77
- if not isinstance(message.get("content"), str)
78
- else message.get("content"),
79
- message.get("tool_call_id"),
80
- message.get("name"),
81
- json.dumps(message.get("tool_calls"), ensure_ascii=False)
82
- if message.get("tool_calls")
83
  else None,
 
84
  time.time(),
85
  ),
86
  )
 
87
  db.commit()
88
 
89
 
90
- def load_memory(conversation_id: str, limit: int = MEMORY_LIMIT):
91
- with sqlite3.connect(DB_PATH) as db:
 
 
 
 
 
 
 
 
 
 
 
 
92
  rows = db.execute(
93
  """
94
- SELECT role, content, tool_call_id, name, tool_calls
 
 
 
 
 
95
  FROM messages
96
  WHERE conversation_id = ?
97
  ORDER BY id DESC
98
  LIMIT ?
99
  """,
100
- (conversation_id, limit),
 
 
 
101
  ).fetchall()
102
 
103
  rows.reverse()
104
 
105
- messages = []
106
 
107
- for role, content, tool_call_id, name, tool_calls in rows:
108
- msg = {"role": role}
 
 
 
 
 
109
 
110
- try:
111
- msg["content"] = json.loads(content)
112
- except Exception:
113
- msg["content"] = content
114
 
115
  if tool_call_id:
116
- msg["tool_call_id"] = tool_call_id
117
 
118
  if name:
119
- msg["name"] = name
120
 
121
  if tool_calls:
122
  try:
123
- msg["tool_calls"] = json.loads(tool_calls)
124
  except Exception:
125
  pass
126
 
127
- messages.append(msg)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
 
129
- return messages
 
 
 
 
 
130
 
131
 
132
  init_db()
@@ -136,77 +274,109 @@ init_db()
136
  # =====================================================
137
 
138
  def _load_cookies_raw() -> Dict[str, Any]:
139
- raw_env = (os.getenv("COOKIES_JSON") or "").strip()
 
 
 
 
140
 
141
  if raw_env:
142
  try:
143
  return json.loads(raw_env)
144
  except Exception as e:
145
- logger.warning(e)
 
 
 
146
 
147
  try:
148
  if os.path.exists("cookies.json"):
149
- with open("cookies.json", "r", encoding="utf-8") as f:
150
- return json.load(f)
 
 
 
 
 
151
  except Exception as e:
152
- logger.warning(e)
 
 
 
153
 
154
  return {}
155
 
156
 
157
  def load_cookies() -> str:
 
158
  data = _load_cookies_raw()
159
 
160
  if not data:
161
- return "⚠️ No Cookies"
162
 
163
  try:
164
  from g4f.cookies import set_cookies
165
  except Exception:
166
- return "⚠️ Cookies Found"
 
 
167
 
168
- for domain, vals in data.items():
169
  try:
170
- dom = domain if "." in domain else f".{domain}.com"
171
-
172
- if isinstance(vals, list):
173
- vals = {
174
- x["name"]: x["value"]
175
- for x in vals
176
- if isinstance(x, dict)
177
- and "name" in x
178
- and "value" in x
 
 
 
 
179
  }
180
 
181
- if isinstance(vals, dict):
182
- set_cookies(dom, vals)
 
 
 
183
 
184
  except Exception as e:
185
- logger.warning(e)
 
 
 
 
186
 
187
- return "✅ Cookies Loaded"
188
 
189
 
190
  COOKIE_STATUS = load_cookies()
191
 
192
  # =====================================================
193
- # PROVIDER — QWEN ONLY
194
  # =====================================================
195
 
196
  def get_provider(name: str):
 
197
  try:
198
- return getattr(g4f.Provider, name)
 
 
 
199
  except Exception:
200
  return None
201
 
202
 
203
- REAL_PROVIDERS = {
204
- "Qwen": get_provider("Qwen"),
205
- }
 
 
 
206
 
207
- REAL_PROVIDERS = {
208
- k: v for k, v in REAL_PROVIDERS.items() if v
209
- }
210
 
211
  QWEN_WORKING_MODELS = [
212
  "qwen-max",
@@ -219,12 +389,19 @@ QWEN_WORKING_MODELS = [
219
  "",
220
  ]
221
 
222
- CLAUDE_MODEL_MAP = {
 
223
  "claude-opus-4-6": "qwen-max",
224
  "claude-sonnet-4-6": "qwen-max",
225
  "claude-haiku-4-5": "qwen-plus",
 
 
 
 
226
  }
227
 
 
 
228
  # =====================================================
229
  # CACHE
230
  # =====================================================
@@ -233,80 +410,213 @@ _CACHE: Dict[str, str] = {}
233
  _CACHE_TS: Dict[str, float] = {}
234
 
235
 
236
- def cache_get(key):
237
- ts = _CACHE_TS.get(key)
238
 
239
- if ts and time.time() - ts < 300:
240
- return _CACHE.get(key)
241
 
242
- return None
 
 
 
 
 
 
 
 
243
 
244
 
245
- def cache_set(key, value):
 
 
 
246
  _CACHE[key] = value
247
  _CACHE_TS[key] = time.time()
248
 
249
  # =====================================================
250
- # STREAM CLEANER
251
  # =====================================================
252
 
253
- def _clean_chunk(chunk) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
  try:
 
 
255
  if isinstance(chunk, dict):
 
256
  choices = chunk.get("choices")
257
 
258
  if choices:
259
- choice = choices[0]
260
 
261
- delta = choice.get("delta") or {}
262
- message = choice.get("message") or {}
263
 
264
- return (
265
- delta.get("content")
266
- or delta.get("text")
267
- or message.get("content")
268
- or ""
 
269
  )
270
 
271
- return chunk.get("content") or chunk.get("text") or ""
 
 
 
272
 
273
- if isinstance(chunk, str):
274
- stripped = chunk.strip()
275
 
276
- if stripped.startswith("{"):
277
- try:
278
- data = json.loads(chunk)
 
279
 
280
- if data.get("code") == "Not_Found":
281
- return ""
282
 
283
- choices = data.get("choices")
284
 
285
- if choices:
286
- choice = choices[0]
287
- delta = choice.get("delta") or {}
288
- message = choice.get("message") or {}
289
 
290
- return (
291
- delta.get("content")
292
- or message.get("content")
293
- or ""
294
- )
295
 
296
- return data.get("content") or data.get("text") or ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
 
298
  except Exception:
299
  pass
300
 
301
- return chunk.replace("\\n", "\n")
 
 
 
 
 
 
302
 
303
  return str(chunk)
304
 
305
  except Exception:
306
  return ""
307
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
308
  # =====================================================
309
- # ORIGINAL ASK — MODEL MODE
310
  # =====================================================
311
 
312
  def ask(
@@ -315,331 +625,914 @@ def ask(
315
  provider_name: str,
316
  model_name: str,
317
  ):
318
- message = (message or "").strip()
 
 
 
 
319
 
320
  if not message and not history:
321
  yield ""
322
  return
323
 
324
- target_model = CLAUDE_MODEL_MAP.get(model_name, model_name)
 
 
 
 
 
 
 
 
 
 
 
325
 
326
- key = (
327
- f"{provider_name}|"
328
- f"{target_model}|"
329
- f"{hash(json.dumps(message, ensure_ascii=False) + json.dumps(history[-3:] if history else [], ensure_ascii=False))}"
330
  )
331
 
332
- cached = cache_get(key)
333
-
334
- if cached:
335
  yield cached
336
  return
337
 
338
- msgs = []
 
 
 
 
 
 
 
 
 
339
 
340
- if history:
341
- for item in history[-20:]:
342
- if isinstance(item, dict):
343
- role = item.get("role")
344
- content = item.get("content")
345
 
346
- if role and content and str(content).strip():
347
- msgs.append({
348
- "role": str(role),
349
- "content": str(content),
350
- })
 
 
 
 
351
 
352
  if message:
353
- msgs.append({
354
  "role": "user",
355
  "content": message,
356
  })
357
 
358
- last_exc = None
359
 
360
- provider = REAL_PROVIDERS.get("Qwen")
361
-
362
- candidates = [target_model] + [
363
- model
364
- for model in QWEN_WORKING_MODELS
365
- if model != target_model
366
- ]
367
 
368
- for model in candidates:
369
  try:
370
  stream = g4f.ChatCompletion.create(
371
- model=model,
372
- provider=provider,
373
- messages=msgs,
374
  stream=True,
375
  )
376
 
377
- text = ""
378
 
379
  for chunk in stream:
380
- content = _clean_chunk(chunk)
381
 
382
- if content:
383
- text += content
384
- yield content
 
 
 
 
 
 
 
 
 
 
 
385
 
386
- if text.strip():
387
- cache_set(key, text)
388
  return
389
 
390
  except Exception as e:
391
- err = str(e)
 
392
 
393
  if (
394
- "Not_Found" in err
395
- or "Model not found" in err
396
  ):
397
  continue
398
 
399
- last_exc = e
400
 
401
- logger.error("Qwen failed: %s", last_exc)
 
 
 
 
402
 
403
  raise RuntimeError(
404
- f"Qwen provider failed: {last_exc}"
405
  )
406
 
407
  # =====================================================
408
- # FULL MESSAGE ASK — AGENT MODE
409
  # =====================================================
410
 
411
- def ask_messages(messages: List[Dict[str, Any]], model_name: str):
412
- target_model = CLAUDE_MODEL_MAP.get(model_name, model_name)
413
-
414
- provider = REAL_PROVIDERS.get("Qwen")
415
 
416
- candidates = [target_model] + [
417
- model
418
- for model in QWEN_WORKING_MODELS
419
- if model != target_model
420
- ]
421
 
422
- last_exc = None
 
 
423
 
424
- for model in candidates:
425
  try:
426
- stream = g4f.ChatCompletion.create(
427
- model=model,
428
- provider=provider,
 
429
  messages=messages,
430
- stream=True,
431
  )
432
 
433
- text = ""
434
-
435
- for chunk in stream:
436
- content = _clean_chunk(chunk)
 
437
 
438
- if content:
439
- text += content
 
440
 
441
- if text.strip():
442
- return text
 
 
 
 
 
 
443
 
444
  except Exception as e:
445
- err = str(e)
 
446
 
447
  if (
448
- "Not_Found" in err
449
- or "Model not found" in err
450
  ):
451
  continue
452
 
453
- last_exc = e
 
 
 
 
 
 
454
 
455
  raise RuntimeError(
456
- f"Qwen provider failed: {last_exc}"
457
  )
458
 
459
 
460
- async def ask_messages_async(messages, model):
 
 
 
 
461
  loop = asyncio.get_running_loop()
462
 
463
  return await loop.run_in_executor(
464
  _POOL,
465
- ask_messages,
466
  messages,
467
- model,
468
  )
469
 
470
  # =====================================================
471
- # AGENT CONTRACTS
 
472
  # =====================================================
473
 
474
- class ToolDecisionStatus(str, Enum):
475
- VALID = "valid"
476
- REPAIRABLE = "repairable"
477
- RETRY_REQUIRED = "retry_required"
478
- BLOCK = "block"
479
- NO_TOOL = "no_tool"
480
 
 
 
481
 
482
- class CanonicalToolCall(BaseModel):
483
- id: str
484
- name: str
485
- arguments: Dict[str, Any] = Field(default_factory=dict)
486
 
 
 
487
 
488
- class ToolDecision(BaseModel):
489
- status: ToolDecisionStatus
490
- tool_calls: List[CanonicalToolCall] = Field(default_factory=list)
491
- reason: Optional[str] = None
492
 
 
 
 
 
 
493
 
494
- class ToolRegistry:
 
 
 
 
495
 
496
- def __init__(self, tools=None):
497
- self.tools = {}
498
 
499
- for tool in tools or []:
500
- if (
501
- isinstance(tool, dict)
502
- and tool.get("type") == "function"
503
- ):
504
- fn = tool.get("function", {})
505
- name = fn.get("name")
506
 
507
- if name:
508
- self.tools[name] = tool
509
 
510
- def has(self, name):
511
- return name in self.tools
 
 
 
512
 
513
- def schema(self, name):
514
- tool = self.tools.get(name)
515
 
516
- if not tool:
517
- return {}
518
 
519
- return (
520
- tool
521
- .get("function", {})
522
- .get("parameters", {})
523
- )
524
 
 
 
 
 
 
 
 
 
 
 
 
525
 
526
- class ToolProcessor:
527
 
528
- def __init__(self, registry):
529
- self.registry = registry
 
 
 
 
 
 
530
 
531
- def process(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
532
  self,
533
- raw_text: Optional[str],
534
- native_calls: Optional[List[dict]] = None,
535
- ) -> ToolDecision:
 
536
 
537
- calls = []
 
 
 
538
 
539
- if native_calls:
540
- for call in native_calls:
541
- try:
542
- fn = call.get("function", {})
543
 
544
- arguments = fn.get("arguments", "{}")
 
 
 
 
545
 
546
- if isinstance(arguments, str):
547
- arguments = json.loads(arguments)
548
 
549
- calls.append(
550
- CanonicalToolCall(
551
- id=call.get("id")
552
- or f"call_{uuid.uuid4().hex[:12]}",
553
- name=fn.get("name", ""),
554
- arguments=arguments,
555
- )
556
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
557
 
 
 
 
 
 
 
 
 
558
  except Exception:
559
- return ToolDecision(
560
- status=ToolDecisionStatus.RETRY_REQUIRED,
561
- reason="Malformed native tool call",
 
 
 
562
  )
563
 
564
- elif raw_text:
565
- text = raw_text.strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
566
 
567
- try:
568
- if "```json" in text:
569
- text = (
570
- text
571
- .split("```json", 1)[1]
572
- .split("```", 1)[0]
573
- .strip()
574
- )
 
 
 
 
 
 
 
 
575
 
576
- parsed = json.loads(text)
 
 
 
577
 
578
- raw_calls = []
 
 
 
 
 
 
579
 
580
- if isinstance(parsed, dict):
581
- if isinstance(parsed.get("tool_calls"), list):
582
- raw_calls = parsed["tool_calls"]
583
 
584
- elif parsed.get("name"):
585
- raw_calls = [parsed]
586
 
587
- for index, call in enumerate(raw_calls):
588
- arguments = call.get("arguments", {})
589
 
590
- if isinstance(arguments, str):
591
- arguments = json.loads(arguments)
592
 
593
- calls.append(
594
- CanonicalToolCall(
595
- id=call.get("id")
596
- or f"call_emu_{index}_{uuid.uuid4().hex[:8]}",
597
- name=call.get("name", ""),
598
- arguments=arguments,
599
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
600
  )
 
601
 
602
- except Exception:
603
- return ToolDecision(
604
- status=ToolDecisionStatus.NO_TOOL
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
605
  )
 
 
 
 
 
 
 
 
 
 
 
606
 
607
  if not calls:
 
608
  return ToolDecision(
609
- status=ToolDecisionStatus.NO_TOOL
 
 
 
610
  )
611
 
612
  for call in calls:
613
 
614
- if not self.registry.has(call.name):
 
615
  return ToolDecision(
616
- status=ToolDecisionStatus.RETRY_REQUIRED,
617
- reason=f"Unknown tool: {call.name}",
 
 
 
 
 
618
  )
619
 
620
- if not isinstance(call.arguments, dict):
 
 
 
621
  return ToolDecision(
622
- status=ToolDecisionStatus.RETRY_REQUIRED,
623
- reason=f"Invalid arguments for {call.name}",
 
 
 
 
 
 
624
  )
625
 
626
- schema = self.registry.schema(call.name)
627
-
628
- if schema:
629
- validator = Draft202012Validator(schema)
630
 
631
- errors = list(
632
- validator.iter_errors(call.arguments)
 
 
633
  )
 
634
 
635
- if errors:
636
- return ToolDecision(
637
- status=ToolDecisionStatus.RETRY_REQUIRED,
638
- reason=errors[0].message,
639
- )
 
 
 
 
 
 
 
 
640
 
641
  return ToolDecision(
642
- status=ToolDecisionStatus.VALID,
 
 
 
643
  tool_calls=calls,
644
  )
645
 
@@ -653,26 +1546,37 @@ _HEDGE_PATTERNS = (
653
  "دعني أتحقق",
654
  "دعني أتأكد",
655
  "سأحاول",
 
656
  "سأنشئ",
657
  "سأبحث",
658
  "انتظر",
659
  "لحظة من فضلك",
 
660
  "let me check",
661
  "let me verify",
662
  "i will",
663
  "i'll",
664
  "hold on",
665
  "give me a moment",
 
 
666
  "please wait",
 
667
  )
668
 
669
 
670
- def looks_like_unfulfilled_intent(text):
 
 
 
671
  if not text:
672
  return False
673
 
674
  lowered = text.strip().lower()
675
 
 
 
 
676
  if len(lowered) > 500:
677
  return False
678
 
@@ -681,172 +1585,353 @@ def looks_like_unfulfilled_intent(text):
681
  for pattern in _HEDGE_PATTERNS
682
  )
683
 
684
- # =====================================================
685
- # TOOL HISTORY SANITIZER
686
- # =====================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
687
 
688
- def sanitize_tool_history(messages):
689
  output = []
690
 
691
  for message in messages:
 
692
  role = message.get("role")
693
 
694
  if role == "tool":
 
695
  output.append({
696
  "role": "user",
697
  "content": (
698
- f"[Tool Result]\n"
699
- f"Tool: {message.get('name', 'tool')}\n"
700
- f"Tool Call ID: {message.get('tool_call_id', '')}\n"
701
- f"Result: {message.get('content', '')}\n\n"
702
- "Decide immediately whether another tool is required. "
703
- "If another tool is needed, output JSON tool_calls only. "
704
- "Otherwise return the complete final answer."
 
705
  ),
706
  })
707
 
708
- elif (
 
 
709
  role == "assistant"
710
  and message.get("tool_calls")
711
  ):
 
712
  reconstructed = []
713
 
714
- for call in message["tool_calls"]:
715
- fn = call.get("function", {})
 
 
716
 
717
- arguments = fn.get("arguments", "{}")
 
 
 
 
718
 
719
- try:
720
- if isinstance(arguments, str):
721
- arguments = json.loads(arguments)
722
- except Exception:
723
- arguments = {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
724
 
725
  reconstructed.append({
726
- "name": fn.get("name"),
 
 
 
727
  "arguments": arguments,
728
  })
729
 
730
  output.append({
731
  "role": "assistant",
732
- "content": json.dumps(
733
- {"tool_calls": reconstructed},
734
- ensure_ascii=False,
735
- ),
736
  })
737
 
738
- else:
739
- output.append({
740
- "role": role,
741
- "content": message.get("content", ""),
742
- })
 
 
 
 
 
 
 
 
 
 
 
743
 
744
  return output
745
 
746
  # =====================================================
747
- # AGENT DIRECTIVES
748
  # =====================================================
749
 
750
- def build_agent_directive(tools, tool_choice=None):
 
 
 
751
 
752
- specs = []
753
 
754
  for tool in tools:
755
- fn = tool.get("function", {})
756
 
757
- specs.append(
758
- f"TOOL NAME: {fn.get('name')}\n"
759
- f"DESCRIPTION: {fn.get('description', '')}\n"
760
- f"JSON SCHEMA:\n"
761
- f"{json.dumps(fn.get('parameters', {}), ensure_ascii=False)}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
762
  )
763
 
764
  forced = ""
765
 
766
  if tool_choice == "required":
 
767
  forced = (
768
- "\nYou MUST call at least one tool now."
 
769
  )
770
 
771
- elif isinstance(tool_choice, dict):
772
- name = (
 
 
 
 
773
  tool_choice
774
  .get("function", {})
775
  .get("name")
776
  )
777
 
778
- if name:
 
779
  forced = (
780
- f"\nYou MUST call tool '{name}' now."
 
781
  )
782
 
783
  return {
784
  "role": "system",
785
  "content": (
786
- "You are an execution agent.\n"
787
- "You can execute actions through external tools.\n\n"
 
 
 
788
 
789
- "When an action requires a tool, output ONLY valid JSON:\n"
 
790
 
791
- '{"tool_calls":[{"name":"tool_name","arguments":{}}]}\n\n'
 
792
 
793
- "Never describe that you will execute something later.\n"
794
- "Never promise to use a tool.\n"
795
- "Either call the tool immediately or provide the complete final answer.\n\n"
 
 
 
 
 
 
796
 
797
  "AVAILABLE TOOLS:\n\n"
798
- + "\n\n".join(specs)
 
 
799
  + forced
800
  ),
801
  }
802
 
803
 
804
  def build_agent_reminder(
805
- tools,
806
- tool_choice=None,
807
- retry_note=None,
808
- ):
809
 
810
- names = [
811
- t.get("function", {}).get("name")
812
- for t in tools
813
- if isinstance(t, dict)
814
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
815
 
816
  forced = ""
817
 
818
  if tool_choice == "required":
819
- forced = "\nAt least one tool call is required."
820
 
821
- elif isinstance(tool_choice, dict):
822
- name = (
 
 
 
 
 
 
 
 
 
823
  tool_choice
824
  .get("function", {})
825
  .get("name")
826
  )
827
 
828
- if name:
 
829
  forced = (
830
- f"\nCall tool '{name}' now."
831
  )
832
 
833
  retry = ""
834
 
835
  if retry_note:
836
- retry = f"\nERROR TO FIX: {retry_note}"
 
 
 
 
 
837
 
838
  return {
839
  "role": "system",
840
  "content": (
841
- "FINAL AGENT REMINDER:\n"
842
-
843
- f"Available tools: {', '.join(names)}\n"
844
-
845
- "If an action is required, output JSON tool_calls only.\n"
846
-
847
- "If no tool is required, return the complete final answer now.\n"
848
-
849
- "Never promise future execution."
850
  + forced
851
  + retry
852
  ),
@@ -861,27 +1946,37 @@ class ChatMessage(BaseModel):
861
  content: Any = None
862
  tool_call_id: Optional[str] = None
863
  name: Optional[str] = None
864
- tool_calls: Optional[List[dict]] = None
 
 
865
 
866
 
867
  class ChatRequest(BaseModel):
868
  model: str = "claude-sonnet-4-6"
869
  messages: List[ChatMessage]
 
870
  stream: bool = False
871
 
872
  temperature: Optional[float] = 0.7
873
  max_tokens: Optional[int] = None
874
 
875
- tools: Optional[List[dict]] = None
 
 
 
876
  tool_choice: Optional[Any] = None
877
 
 
 
878
  conversation_id: Optional[str] = None
879
 
880
  # =====================================================
881
  # AUTH
882
  # =====================================================
883
 
884
- def verify(request: Request):
 
 
885
 
886
  auth = request.headers.get(
887
  "Authorization",
@@ -892,60 +1987,164 @@ def verify(request: Request):
892
  auth.startswith("Bearer ")
893
  and auth[7:] == API_KEY
894
  ):
895
- return
896
 
897
  if (
898
- request.headers.get("X-API-Key")
 
 
 
899
  == API_KEY
900
  ):
901
- return
902
 
903
  raise HTTPException(
904
  status_code=401,
905
- detail="Invalid or missing API key",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
906
  )
907
 
 
 
 
 
 
 
 
 
 
908
  # =====================================================
909
  # APP
910
  # =====================================================
911
 
912
  app = FastAPI(
913
- title="G4F Qwen OpenAI Agent Gateway"
 
914
  )
915
 
916
  # =====================================================
917
  # ROOT
918
  # =====================================================
919
 
920
- @app.get("/")
 
 
 
921
  async def root():
922
- return {
 
923
  "status": "running",
924
  "provider": "Qwen",
925
  "cookies": COOKIE_STATUS,
926
  "memory": "SQLite",
927
- }
 
928
 
929
  # =====================================================
930
  # MODELS
931
  # =====================================================
932
 
933
  @app.get("/v1/models")
934
- async def models(request: Request):
 
 
935
 
936
  verify(request)
937
 
938
- return {
939
  "object": "list",
940
  "data": [
941
  {
942
- "id": name,
943
  "object": "model",
 
944
  "owned_by": "qwen",
945
  }
946
- for name in CLAUDE_MODEL_MAP
947
  ],
948
- }
949
 
950
  # =====================================================
951
  # CHAT COMPLETIONS
@@ -959,70 +2158,55 @@ async def chat_completions(
959
 
960
  verify(request)
961
 
 
 
 
 
 
 
 
962
  request_id = (
963
- f"chatcmpl-{uuid.uuid4().hex[:24]}"
 
 
 
 
 
964
  )
965
 
966
- created = int(time.time())
 
 
 
 
 
967
 
968
  incoming_messages = [
969
- message.model_dump(exclude_none=True)
 
 
970
  for message in body.messages
971
  ]
972
 
973
- conversation_id = (
974
- body.conversation_id
975
- or request.headers.get("X-Conversation-ID")
976
- or "default"
977
  )
978
 
979
- stored_memory = load_memory(conversation_id)
980
-
981
- # منع تكرار الرسائل عند إرسال العميل
982
- # تاريخ المحادثة كاملاً في كل طلب.
983
- if stored_memory and incoming_messages:
984
-
985
- incoming_hashes = {
986
- hashlib.sha256(
987
- json.dumps(
988
- message,
989
- sort_keys=True,
990
- ensure_ascii=False,
991
- ).encode()
992
- ).hexdigest()
993
- for message in stored_memory
994
- }
995
-
996
- new_messages = []
997
-
998
- for message in incoming_messages:
999
-
1000
- digest = hashlib.sha256(
1001
- json.dumps(
1002
- message,
1003
- sort_keys=True,
1004
- ensure_ascii=False,
1005
- ).encode()
1006
- ).hexdigest()
1007
-
1008
- if digest not in incoming_hashes:
1009
- new_messages.append(message)
1010
-
1011
- incoming_messages = (
1012
- stored_memory + new_messages
1013
  )
 
1014
 
1015
- elif stored_memory:
1016
- incoming_messages = stored_memory
1017
-
1018
- for message in body.messages:
1019
- save_message(
1020
- conversation_id,
1021
- message.model_dump(exclude_none=True),
1022
- )
1023
 
1024
  # =================================================
1025
- # MODEL MODE — ORIGINAL STREAMING BEHAVIOR
 
1026
  # =================================================
1027
 
1028
  if not body.tools:
@@ -1030,59 +2214,104 @@ async def chat_completions(
1030
  last_user_index = None
1031
 
1032
  for index in range(
1033
- len(incoming_messages) - 1,
1034
  -1,
1035
  -1,
1036
  ):
 
1037
  if (
1038
- incoming_messages[index].get("role")
 
1039
  == "user"
1040
  ):
1041
  last_user_index = index
1042
  break
1043
 
1044
  if last_user_index is None:
 
1045
  raise HTTPException(
1046
  status_code=400,
1047
- detail="User message required",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1048
  )
 
1049
 
1050
- last_user = str(
1051
- incoming_messages[last_user_index]
1052
- .get("content", "")
 
1053
  )
1054
 
1055
- history = incoming_messages[:last_user_index]
 
 
1056
 
1057
  if body.stream:
1058
 
1059
- async def model_stream():
1060
 
1061
  queue = asyncio.Queue()
1062
- loop = asyncio.get_running_loop()
 
 
1063
 
1064
  def producer():
 
1065
  try:
1066
- for chunk in ask(
1067
- last_user,
 
1068
  history,
1069
  "Qwen",
1070
  body.model,
1071
  ):
 
1072
  asyncio.run_coroutine_threadsafe(
1073
- queue.put(("chunk", chunk)),
 
 
 
 
 
1074
  loop,
1075
  )
1076
 
1077
  except Exception as e:
 
1078
  asyncio.run_coroutine_threadsafe(
1079
- queue.put(("error", e)),
 
 
 
 
 
1080
  loop,
1081
  )
1082
 
1083
  finally:
 
1084
  asyncio.run_coroutine_threadsafe(
1085
- queue.put(("done", None)),
 
 
 
 
 
1086
  loop,
1087
  )
1088
 
@@ -1091,19 +2320,23 @@ async def chat_completions(
1091
  producer,
1092
  )
1093
 
1094
- full = ""
1095
 
1096
  while True:
1097
 
1098
- kind, value = await queue.get()
 
 
1099
 
1100
- if kind == "chunk":
1101
 
1102
- full += value
1103
 
1104
- payload = {
1105
  "id": request_id,
1106
- "object": "chat.completion.chunk",
 
 
1107
  "created": created,
1108
  "model": body.model,
1109
  "choices": [{
@@ -1118,39 +2351,52 @@ async def chat_completions(
1118
  yield (
1119
  "data: "
1120
  + json.dumps(
1121
- payload,
1122
  ensure_ascii=False,
1123
  )
1124
  + "\n\n"
1125
  )
1126
 
1127
- elif kind == "error":
 
 
 
 
 
 
 
 
 
 
 
1128
 
1129
  yield (
1130
  "data: "
1131
- + json.dumps({
1132
- "error": {
1133
- "message": str(value)
1134
- }
1135
- })
1136
  + "\n\n"
1137
  )
1138
 
1139
- elif kind == "done":
1140
  break
1141
 
1142
- if full:
1143
- save_message(
 
1144
  conversation_id,
1145
  {
1146
  "role": "assistant",
1147
- "content": full,
1148
  },
1149
  )
1150
 
1151
  final_chunk = {
1152
  "id": request_id,
1153
- "object": "chat.completion.chunk",
 
 
1154
  "created": created,
1155
  "model": body.model,
1156
  "choices": [{
@@ -1162,38 +2408,96 @@ async def chat_completions(
1162
 
1163
  yield (
1164
  "data: "
1165
- + json.dumps(final_chunk)
 
 
 
1166
  + "\n\n"
1167
  )
1168
 
1169
  yield "data: [DONE]\n\n"
1170
 
1171
  return StreamingResponse(
1172
- model_stream(),
1173
- media_type="text/event-stream",
 
 
1174
  headers={
1175
- "Cache-Control": "no-cache",
1176
- "Connection": "keep-alive",
1177
- "X-Accel-Buffering": "no",
 
 
 
 
 
 
1178
  },
1179
  )
1180
 
1181
- response_text = await ask_messages_async(
1182
- incoming_messages,
1183
- body.model,
1184
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1185
 
1186
  assistant_message = {
1187
  "role": "assistant",
1188
  "content": response_text,
1189
  }
1190
 
1191
- save_message(
1192
  conversation_id,
1193
  assistant_message,
1194
  )
1195
 
1196
- return {
 
 
 
 
 
 
 
1197
  "id": request_id,
1198
  "object": "chat.completion",
1199
  "created": created,
@@ -1202,62 +2506,81 @@ async def chat_completions(
1202
  "index": 0,
1203
  "message": assistant_message,
1204
  "finish_reason": "stop",
 
1205
  }],
1206
- "usage": {
1207
- "prompt_tokens": 0,
1208
- "completion_tokens": len(response_text) // 4,
1209
- "total_tokens": len(response_text) // 4,
1210
- },
1211
- }
1212
 
1213
  # =================================================
1214
  # AGENT MODE
 
1215
  # =================================================
1216
 
1217
- registry = ToolRegistry(body.tools)
1218
- processor = ToolProcessor(registry)
 
 
 
 
 
1219
 
1220
- agent_messages = sanitize_tool_history(
1221
- incoming_messages
 
 
1222
  )
1223
 
1224
  agent_messages = (
1225
- [build_agent_directive(
1226
- body.tools,
1227
- body.tool_choice,
1228
- )]
1229
- + agent_messages
1230
- + [build_agent_reminder(
1231
- body.tools,
1232
- body.tool_choice,
1233
- )]
 
 
 
 
1234
  )
1235
 
1236
- retries = 0
1237
 
1238
  while True:
1239
 
1240
  try:
1241
- response_text = (
1242
- await ask_messages_async(
 
 
 
 
1243
  agent_messages,
1244
  body.model,
1245
  )
1246
  )
1247
 
1248
  except Exception as e:
 
1249
  raise HTTPException(
1250
  status_code=502,
1251
- detail=f"Qwen failed: {e}",
 
 
1252
  )
1253
 
1254
  decision = processor.process(
1255
  raw_text=response_text,
1256
- native_calls=None,
1257
  )
1258
 
1259
  # =============================================
1260
- # INVALID TOOL CALL → RETRY
1261
  # =============================================
1262
 
1263
  if (
@@ -1265,46 +2588,59 @@ async def chat_completions(
1265
  == ToolDecisionStatus.RETRY_REQUIRED
1266
  ):
1267
 
1268
- retries += 1
 
 
 
 
 
1269
 
1270
- if retries > MAX_TOOL_RETRIES:
1271
  raise HTTPException(
1272
  status_code=502,
1273
  detail=(
1274
- "Model failed to produce "
1275
- "a valid tool call"
 
1276
  ),
1277
  )
1278
 
1279
  agent_messages.append({
1280
  "role": "assistant",
1281
- "content": response_text,
 
 
 
1282
  })
1283
 
1284
  agent_messages.append(
1285
  build_agent_reminder(
1286
  body.tools,
1287
  body.tool_choice,
1288
- retry_note=decision.reason,
 
 
 
 
1289
  )
1290
  )
1291
 
1292
  continue
1293
 
1294
  # =============================================
1295
- # HEDGING → RETRY
1296
  # =============================================
1297
 
1298
  if (
1299
  decision.status
1300
  == ToolDecisionStatus.NO_TOOL
 
 
1301
  and looks_like_unfulfilled_intent(
1302
  response_text
1303
  )
1304
- and retries < MAX_TOOL_RETRIES
1305
  ):
1306
 
1307
- retries += 1
1308
 
1309
  agent_messages.append({
1310
  "role": "assistant",
@@ -1316,9 +2652,11 @@ async def chat_completions(
1316
  body.tools,
1317
  body.tool_choice,
1318
  retry_note=(
1319
- "Do not promise execution. "
1320
- "Call the required tool now "
1321
- "or return the final answer."
 
 
1322
  ),
1323
  )
1324
  )
@@ -1327,6 +2665,7 @@ async def chat_completions(
1327
 
1328
  # =============================================
1329
  # VALID TOOL CALLS
 
1330
  # =============================================
1331
 
1332
  if (
@@ -1338,7 +2677,7 @@ async def chat_completions(
1338
  and decision.tool_calls
1339
  ):
1340
 
1341
- tool_calls = [
1342
  {
1343
  "id": call.id,
1344
  "type": "function",
@@ -1347,39 +2686,60 @@ async def chat_completions(
1347
  "arguments": json.dumps(
1348
  call.arguments,
1349
  ensure_ascii=False,
 
 
 
 
1350
  ),
1351
  },
1352
  }
1353
- for call in decision.tool_calls
 
1354
  ]
1355
 
1356
  assistant_message = {
1357
  "role": "assistant",
1358
  "content": None,
1359
- "tool_calls": tool_calls,
 
 
1360
  }
1361
 
1362
- save_message(
1363
  conversation_id,
1364
  assistant_message,
1365
  )
1366
 
1367
- return {
 
 
 
 
 
 
 
 
 
 
1368
  "id": request_id,
1369
  "object": "chat.completion",
1370
  "created": created,
1371
  "model": body.model,
1372
  "choices": [{
1373
  "index": 0,
1374
- "message": assistant_message,
1375
- "finish_reason": "tool_calls",
 
 
 
 
 
1376
  }],
1377
- "usage": {
1378
- "prompt_tokens": 0,
1379
- "completion_tokens": 0,
1380
- "total_tokens": 0,
1381
- },
1382
- }
1383
 
1384
  # =============================================
1385
  # FINAL ANSWER
@@ -1390,12 +2750,19 @@ async def chat_completions(
1390
  "content": response_text,
1391
  }
1392
 
1393
- save_message(
1394
  conversation_id,
1395
  assistant_message,
1396
  )
1397
 
1398
- return {
 
 
 
 
 
 
 
1399
  "id": request_id,
1400
  "object": "chat.completion",
1401
  "created": created,
@@ -1404,18 +2771,13 @@ async def chat_completions(
1404
  "index": 0,
1405
  "message": assistant_message,
1406
  "finish_reason": "stop",
 
1407
  }],
1408
- "usage": {
1409
- "prompt_tokens": 0,
1410
- "completion_tokens": (
1411
- len(response_text) // 4
1412
- ),
1413
- "total_tokens": (
1414
- len(response_text) // 4
1415
- ),
1416
- },
1417
- }
1418
-
1419
 
1420
  # =====================================================
1421
  # RUN
@@ -1436,4 +2798,8 @@ if __name__ == "__main__":
1436
  app,
1437
  host="0.0.0.0",
1438
  port=port,
 
 
 
 
1439
  )
 
5
  import logging
6
  import asyncio
7
  import hashlib
 
8
  import sqlite3
9
  from enum import Enum
10
  from concurrent.futures import ThreadPoolExecutor
 
14
  from fastapi import FastAPI, HTTPException, Request
15
  from fastapi.responses import StreamingResponse, JSONResponse
16
  from pydantic import BaseModel, Field
 
17
 
18
  logging.basicConfig(
19
+ level=os.getenv("LOG_LEVEL", "INFO").upper(),
20
+ format="%(asctime)s [%(levelname)s] %(message)s",
21
  )
22
+ logger = logging.getLogger("g4f-qwen-openai-agent")
23
 
24
+ # =====================================================
25
+ # CONFIG
26
+ # =====================================================
27
+
28
+ _POOL = ThreadPoolExecutor(
29
+ max_workers=int(os.getenv("WORKERS", "8"))
30
+ )
31
 
32
  API_KEY = os.getenv("API_KEY", "mysecretkey123")
33
  DB_PATH = os.getenv("DB_PATH", "memory.db")
34
+
35
+ CACHE_TTL = int(os.getenv("CACHE_TTL", "300"))
36
  MEMORY_LIMIT = int(os.getenv("MEMORY_LIMIT", "100"))
37
+ MAX_TOOL_RETRIES = int(os.getenv("MAX_TOOL_RETRIES", "2"))
38
 
39
  # =====================================================
40
  # SQLITE MEMORY
41
  # =====================================================
42
 
43
+ _DB_LOCK = asyncio.Lock()
44
+
45
+
46
+ def _db_connect():
47
+ db = sqlite3.connect(
48
+ DB_PATH,
49
+ timeout=30,
50
+ check_same_thread=False,
51
+ )
52
+ db.execute("PRAGMA journal_mode=WAL")
53
+ db.execute("PRAGMA synchronous=NORMAL")
54
+ db.execute("PRAGMA busy_timeout=30000")
55
+ return db
56
+
57
+
58
  def init_db():
59
+ with _db_connect() as db:
60
+ db.execute(
61
+ """
62
  CREATE TABLE IF NOT EXISTS messages (
63
  id INTEGER PRIMARY KEY AUTOINCREMENT,
64
  conversation_id TEXT NOT NULL,
 
67
  tool_call_id TEXT,
68
  name TEXT,
69
  tool_calls TEXT,
70
+ message_hash TEXT NOT NULL,
71
+ created_at REAL NOT NULL,
72
+ UNIQUE(conversation_id, message_hash)
73
  )
74
+ """
75
+ )
76
+
77
+ db.execute(
78
+ """
79
  CREATE INDEX IF NOT EXISTS idx_messages_conversation
80
  ON messages(conversation_id, id)
81
+ """
82
+ )
83
+
84
  db.commit()
85
 
86
 
87
+ def _canonical_json(value: Any) -> str:
88
+ return json.dumps(
89
+ value,
90
+ ensure_ascii=False,
91
+ sort_keys=True,
92
+ separators=(",", ":"),
93
+ default=str,
94
+ )
95
+
96
+
97
+ def _message_hash(message: Dict[str, Any]) -> str:
98
+ return hashlib.sha256(
99
+ _canonical_json(message).encode("utf-8")
100
+ ).hexdigest()
101
+
102
+
103
+ def _encode_content(content: Any) -> str:
104
+ return _canonical_json(content)
105
+
106
+
107
+ def _decode_content(content: Optional[str]) -> Any:
108
+ if content is None:
109
+ return None
110
+
111
+ try:
112
+ return json.loads(content)
113
+ except Exception:
114
+ return content
115
+
116
+
117
+ def save_message_sync(
118
+ conversation_id: str,
119
+ message: Dict[str, Any],
120
+ ):
121
+ normalized = {
122
+ key: value
123
+ for key, value in message.items()
124
+ if value is not None
125
+ }
126
+
127
+ message_hash = _message_hash(normalized)
128
+
129
+ with _db_connect() as db:
130
  db.execute(
131
  """
132
+ INSERT OR IGNORE INTO messages (
133
  conversation_id,
134
  role,
135
  content,
136
  tool_call_id,
137
  name,
138
  tool_calls,
139
+ message_hash,
140
  created_at
141
  )
142
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
143
  """,
144
  (
145
  conversation_id,
146
+ normalized.get("role", "user"),
147
+ _encode_content(normalized.get("content")),
148
+ normalized.get("tool_call_id"),
149
+ normalized.get("name"),
150
+ _canonical_json(normalized.get("tool_calls"))
151
+ if normalized.get("tool_calls") is not None
 
 
152
  else None,
153
+ message_hash,
154
  time.time(),
155
  ),
156
  )
157
+
158
  db.commit()
159
 
160
 
161
+ def save_messages_sync(
162
+ conversation_id: str,
163
+ messages: List[Dict[str, Any]],
164
+ ):
165
+ for message in messages:
166
+ save_message_sync(conversation_id, message)
167
+
168
+
169
+ def load_memory_sync(
170
+ conversation_id: str,
171
+ limit: int = MEMORY_LIMIT,
172
+ ) -> List[Dict[str, Any]]:
173
+
174
+ with _db_connect() as db:
175
  rows = db.execute(
176
  """
177
+ SELECT
178
+ role,
179
+ content,
180
+ tool_call_id,
181
+ name,
182
+ tool_calls
183
  FROM messages
184
  WHERE conversation_id = ?
185
  ORDER BY id DESC
186
  LIMIT ?
187
  """,
188
+ (
189
+ conversation_id,
190
+ limit,
191
+ ),
192
  ).fetchall()
193
 
194
  rows.reverse()
195
 
196
+ result = []
197
 
198
+ for (
199
+ role,
200
+ content,
201
+ tool_call_id,
202
+ name,
203
+ tool_calls,
204
+ ) in rows:
205
 
206
+ message = {
207
+ "role": role,
208
+ "content": _decode_content(content),
209
+ }
210
 
211
  if tool_call_id:
212
+ message["tool_call_id"] = tool_call_id
213
 
214
  if name:
215
+ message["name"] = name
216
 
217
  if tool_calls:
218
  try:
219
+ message["tool_calls"] = json.loads(tool_calls)
220
  except Exception:
221
  pass
222
 
223
+ result.append(message)
224
+
225
+ return result
226
+
227
+
228
+ async def save_message(
229
+ conversation_id: str,
230
+ message: Dict[str, Any],
231
+ ):
232
+ loop = asyncio.get_running_loop()
233
+
234
+ await loop.run_in_executor(
235
+ _POOL,
236
+ save_message_sync,
237
+ conversation_id,
238
+ message,
239
+ )
240
+
241
+
242
+ async def save_messages(
243
+ conversation_id: str,
244
+ messages: List[Dict[str, Any]],
245
+ ):
246
+ loop = asyncio.get_running_loop()
247
+
248
+ await loop.run_in_executor(
249
+ _POOL,
250
+ save_messages_sync,
251
+ conversation_id,
252
+ messages,
253
+ )
254
+
255
+
256
+ async def load_memory(
257
+ conversation_id: str,
258
+ ) -> List[Dict[str, Any]]:
259
+
260
+ loop = asyncio.get_running_loop()
261
 
262
+ return await loop.run_in_executor(
263
+ _POOL,
264
+ load_memory_sync,
265
+ conversation_id,
266
+ MEMORY_LIMIT,
267
+ )
268
 
269
 
270
  init_db()
 
274
  # =====================================================
275
 
276
  def _load_cookies_raw() -> Dict[str, Any]:
277
+
278
+ raw_env = (
279
+ os.getenv("COOKIES_JSON")
280
+ or ""
281
+ ).strip()
282
 
283
  if raw_env:
284
  try:
285
  return json.loads(raw_env)
286
  except Exception as e:
287
+ logger.warning(
288
+ "Invalid COOKIES_JSON: %s",
289
+ e,
290
+ )
291
 
292
  try:
293
  if os.path.exists("cookies.json"):
294
+ with open(
295
+ "cookies.json",
296
+ "r",
297
+ encoding="utf-8",
298
+ ) as file:
299
+ return json.load(file)
300
+
301
  except Exception as e:
302
+ logger.warning(
303
+ "cookies.json error: %s",
304
+ e,
305
+ )
306
 
307
  return {}
308
 
309
 
310
  def load_cookies() -> str:
311
+
312
  data = _load_cookies_raw()
313
 
314
  if not data:
315
+ return "No Cookies"
316
 
317
  try:
318
  from g4f.cookies import set_cookies
319
  except Exception:
320
+ return "Cookies Found"
321
+
322
+ for domain, values in data.items():
323
 
 
324
  try:
325
+ normalized_domain = (
326
+ domain
327
+ if "." in domain
328
+ else f".{domain}.com"
329
+ )
330
+
331
+ if isinstance(values, list):
332
+ values = {
333
+ item["name"]: item["value"]
334
+ for item in values
335
+ if isinstance(item, dict)
336
+ and item.get("name")
337
+ and "value" in item
338
  }
339
 
340
+ if isinstance(values, dict):
341
+ set_cookies(
342
+ normalized_domain,
343
+ values,
344
+ )
345
 
346
  except Exception as e:
347
+ logger.warning(
348
+ "Cookie load error for %s: %s",
349
+ domain,
350
+ e,
351
+ )
352
 
353
+ return "Cookies Loaded"
354
 
355
 
356
  COOKIE_STATUS = load_cookies()
357
 
358
  # =====================================================
359
+ # QWEN PROVIDER ONLY
360
  # =====================================================
361
 
362
  def get_provider(name: str):
363
+
364
  try:
365
+ return getattr(
366
+ g4f.Provider,
367
+ name,
368
+ )
369
  except Exception:
370
  return None
371
 
372
 
373
+ QWEN_PROVIDER = get_provider("Qwen")
374
+
375
+ if not QWEN_PROVIDER:
376
+ logger.warning(
377
+ "Qwen provider was not found in installed g4f version."
378
+ )
379
 
 
 
 
380
 
381
  QWEN_WORKING_MODELS = [
382
  "qwen-max",
 
389
  "",
390
  ]
391
 
392
+
393
+ MODEL_MAP = {
394
  "claude-opus-4-6": "qwen-max",
395
  "claude-sonnet-4-6": "qwen-max",
396
  "claude-haiku-4-5": "qwen-plus",
397
+ "qwen-max": "qwen-max",
398
+ "qwen-max-latest": "qwen-max-latest",
399
+ "qwen-plus": "qwen-plus",
400
+ "qwen-turbo": "qwen-turbo",
401
  }
402
 
403
+ PUBLIC_MODELS = list(MODEL_MAP.keys())
404
+
405
  # =====================================================
406
  # CACHE
407
  # =====================================================
 
410
  _CACHE_TS: Dict[str, float] = {}
411
 
412
 
413
+ def cache_get(key: str) -> Optional[str]:
 
414
 
415
+ timestamp = _CACHE_TS.get(key)
 
416
 
417
+ if timestamp is None:
418
+ return None
419
+
420
+ if time.time() - timestamp >= CACHE_TTL:
421
+ _CACHE.pop(key, None)
422
+ _CACHE_TS.pop(key, None)
423
+ return None
424
+
425
+ return _CACHE.get(key)
426
 
427
 
428
+ def cache_set(
429
+ key: str,
430
+ value: str,
431
+ ):
432
  _CACHE[key] = value
433
  _CACHE_TS[key] = time.time()
434
 
435
  # =====================================================
436
+ # G4F RESPONSE HELPERS
437
  # =====================================================
438
 
439
+ def _to_dict(value: Any) -> Any:
440
+
441
+ if isinstance(value, dict):
442
+ return value
443
+
444
+ if hasattr(value, "model_dump"):
445
+ try:
446
+ return value.model_dump()
447
+ except Exception:
448
+ pass
449
+
450
+ if hasattr(value, "to_json"):
451
+ try:
452
+ raw = value.to_json()
453
+
454
+ if isinstance(raw, str):
455
+ return json.loads(raw)
456
+
457
+ except Exception:
458
+ pass
459
+
460
+ return value
461
+
462
+
463
+ def _clean_chunk(chunk: Any) -> str:
464
+
465
  try:
466
+ chunk = _to_dict(chunk)
467
+
468
  if isinstance(chunk, dict):
469
+
470
  choices = chunk.get("choices")
471
 
472
  if choices:
 
473
 
474
+ choice = _to_dict(choices[0])
 
475
 
476
+ if not isinstance(choice, dict):
477
+ return ""
478
+
479
+ delta = _to_dict(
480
+ choice.get("delta")
481
+ or {}
482
  )
483
 
484
+ message = _to_dict(
485
+ choice.get("message")
486
+ or {}
487
+ )
488
 
489
+ if isinstance(delta, dict):
 
490
 
491
+ content = (
492
+ delta.get("content")
493
+ or delta.get("text")
494
+ )
495
 
496
+ if content:
497
+ return str(content)
498
 
499
+ if isinstance(message, dict):
500
 
501
+ content = message.get("content")
 
 
 
502
 
503
+ if content:
504
+ return str(content)
 
 
 
505
 
506
+ content = choice.get("text")
507
+
508
+ if content:
509
+ return str(content)
510
+
511
+ content = (
512
+ chunk.get("content")
513
+ or chunk.get("text")
514
+ )
515
+
516
+ return (
517
+ str(content)
518
+ if content
519
+ else ""
520
+ )
521
+
522
+ if isinstance(chunk, str):
523
+
524
+ stripped = chunk.strip()
525
+
526
+ if stripped.startswith("{"):
527
+
528
+ try:
529
+ parsed = json.loads(stripped)
530
+ return _clean_chunk(parsed)
531
 
532
  except Exception:
533
  pass
534
 
535
+ return chunk.replace(
536
+ "\\n",
537
+ "\n",
538
+ )
539
+
540
+ if chunk is None:
541
+ return ""
542
 
543
  return str(chunk)
544
 
545
  except Exception:
546
  return ""
547
 
548
+
549
+ def _extract_native_tool_calls(
550
+ response: Any,
551
+ ) -> Optional[List[Dict[str, Any]]]:
552
+
553
+ payload = _to_dict(response)
554
+
555
+ if not isinstance(payload, dict):
556
+ return None
557
+
558
+ choices = payload.get("choices")
559
+
560
+ if not choices:
561
+ return None
562
+
563
+ choice = _to_dict(choices[0])
564
+
565
+ if not isinstance(choice, dict):
566
+ return None
567
+
568
+ message = _to_dict(
569
+ choice.get("message")
570
+ or choice.get("delta")
571
+ or {}
572
+ )
573
+
574
+ if not isinstance(message, dict):
575
+ return None
576
+
577
+ tool_calls = message.get("tool_calls")
578
+
579
+ return (
580
+ tool_calls
581
+ if isinstance(tool_calls, list)
582
+ else None
583
+ )
584
+
585
+ # =====================================================
586
+ # MODEL FALLBACK
587
+ # =====================================================
588
+
589
+ def resolve_model(
590
+ requested_model: str,
591
+ ) -> str:
592
+
593
+ return MODEL_MAP.get(
594
+ requested_model,
595
+ requested_model,
596
+ )
597
+
598
+
599
+ def model_candidates(
600
+ requested_model: str,
601
+ ) -> List[str]:
602
+
603
+ target = resolve_model(
604
+ requested_model
605
+ )
606
+
607
+ result = []
608
+
609
+ for model in (
610
+ [target]
611
+ + QWEN_WORKING_MODELS
612
+ ):
613
+ if model not in result:
614
+ result.append(model)
615
+
616
+ return result
617
+
618
  # =====================================================
619
+ # ORIGINAL MODEL MODE ASK
620
  # =====================================================
621
 
622
  def ask(
 
625
  provider_name: str,
626
  model_name: str,
627
  ):
628
+
629
+ message = (
630
+ message
631
+ or ""
632
+ ).strip()
633
 
634
  if not message and not history:
635
  yield ""
636
  return
637
 
638
+ cache_payload = {
639
+ "provider": provider_name,
640
+ "model": model_name,
641
+ "message": message,
642
+ "history": history[-20:],
643
+ }
644
+
645
+ cache_key = hashlib.sha256(
646
+ _canonical_json(
647
+ cache_payload
648
+ ).encode("utf-8")
649
+ ).hexdigest()
650
 
651
+ cached = cache_get(
652
+ cache_key
 
 
653
  )
654
 
655
+ if cached is not None:
 
 
656
  yield cached
657
  return
658
 
659
+ messages = []
660
+
661
+ for item in (
662
+ history[-20:]
663
+ if history
664
+ else []
665
+ ):
666
+
667
+ if not isinstance(item, dict):
668
+ continue
669
 
670
+ role = item.get("role")
671
+ content = item.get("content")
 
 
 
672
 
673
+ if (
674
+ role
675
+ and content is not None
676
+ and str(content).strip()
677
+ ):
678
+ messages.append({
679
+ "role": str(role),
680
+ "content": str(content),
681
+ })
682
 
683
  if message:
684
+ messages.append({
685
  "role": "user",
686
  "content": message,
687
  })
688
 
689
+ last_error = None
690
 
691
+ for candidate in model_candidates(
692
+ model_name
693
+ ):
 
 
 
 
694
 
 
695
  try:
696
  stream = g4f.ChatCompletion.create(
697
+ model=candidate,
698
+ provider=QWEN_PROVIDER,
699
+ messages=messages,
700
  stream=True,
701
  )
702
 
703
+ full_text = ""
704
 
705
  for chunk in stream:
 
706
 
707
+ text = _clean_chunk(
708
+ chunk
709
+ )
710
+
711
+ if text:
712
+ full_text += text
713
+ yield text
714
+
715
+ if full_text.strip():
716
+
717
+ cache_set(
718
+ cache_key,
719
+ full_text,
720
+ )
721
 
 
 
722
  return
723
 
724
  except Exception as e:
725
+
726
+ error_text = str(e)
727
 
728
  if (
729
+ "Not_Found" in error_text
730
+ or "Model not found" in error_text
731
  ):
732
  continue
733
 
734
+ last_error = e
735
 
736
+ logger.warning(
737
+ "Qwen model %r failed: %s",
738
+ candidate,
739
+ e,
740
+ )
741
 
742
  raise RuntimeError(
743
+ f"Qwen provider failed: {last_error}"
744
  )
745
 
746
  # =====================================================
747
+ # FULL MESSAGE CALL — AGENT MODE
748
  # =====================================================
749
 
750
+ def call_qwen_full_messages(
751
+ messages: List[Dict[str, Any]],
752
+ model_name: str,
753
+ ) -> Tuple[str, Optional[List[Dict[str, Any]]]]:
754
 
755
+ last_error = None
 
 
 
 
756
 
757
+ for candidate in model_candidates(
758
+ model_name
759
+ ):
760
 
 
761
  try:
762
+
763
+ response = g4f.ChatCompletion.create(
764
+ model=candidate,
765
+ provider=QWEN_PROVIDER,
766
  messages=messages,
767
+ stream=False,
768
  )
769
 
770
+ native_tool_calls = (
771
+ _extract_native_tool_calls(
772
+ response
773
+ )
774
+ )
775
 
776
+ text = _clean_chunk(
777
+ response
778
+ )
779
 
780
+ if (
781
+ native_tool_calls
782
+ or text.strip()
783
+ ):
784
+ return (
785
+ text,
786
+ native_tool_calls,
787
+ )
788
 
789
  except Exception as e:
790
+
791
+ error_text = str(e)
792
 
793
  if (
794
+ "Not_Found" in error_text
795
+ or "Model not found" in error_text
796
  ):
797
  continue
798
 
799
+ last_error = e
800
+
801
+ logger.warning(
802
+ "Qwen agent model %r failed: %s",
803
+ candidate,
804
+ e,
805
+ )
806
 
807
  raise RuntimeError(
808
+ f"Qwen provider failed: {last_error}"
809
  )
810
 
811
 
812
+ async def call_qwen_full_messages_async(
813
+ messages: List[Dict[str, Any]],
814
+ model_name: str,
815
+ ):
816
+
817
  loop = asyncio.get_running_loop()
818
 
819
  return await loop.run_in_executor(
820
  _POOL,
821
+ call_qwen_full_messages,
822
  messages,
823
+ model_name,
824
  )
825
 
826
  # =====================================================
827
+ # LIGHTWEIGHT JSON SCHEMA VALIDATION
828
+ # NO EXTERNAL jsonschema DEPENDENCY
829
  # =====================================================
830
 
831
+ def _matches_schema_type(
832
+ value: Any,
833
+ schema_type: str,
834
+ ) -> bool:
 
 
835
 
836
+ if schema_type == "object":
837
+ return isinstance(value, dict)
838
 
839
+ if schema_type == "array":
840
+ return isinstance(value, list)
 
 
841
 
842
+ if schema_type == "string":
843
+ return isinstance(value, str)
844
 
845
+ if schema_type == "boolean":
846
+ return isinstance(value, bool)
 
 
847
 
848
+ if schema_type == "integer":
849
+ return (
850
+ isinstance(value, int)
851
+ and not isinstance(value, bool)
852
+ )
853
 
854
+ if schema_type == "number":
855
+ return (
856
+ isinstance(value, (int, float))
857
+ and not isinstance(value, bool)
858
+ )
859
 
860
+ if schema_type == "null":
861
+ return value is None
862
 
863
+ return True
 
 
 
 
 
 
864
 
 
 
865
 
866
+ def validate_schema(
867
+ value: Any,
868
+ schema: Dict[str, Any],
869
+ path: str = "$",
870
+ ) -> Optional[str]:
871
 
872
+ if not isinstance(schema, dict):
873
+ return None
874
 
875
+ schema_type = schema.get("type")
 
876
 
877
+ if isinstance(schema_type, list):
 
 
 
 
878
 
879
+ if not any(
880
+ _matches_schema_type(
881
+ value,
882
+ current_type,
883
+ )
884
+ for current_type in schema_type
885
+ ):
886
+ return (
887
+ f"{path}: expected one of "
888
+ f"{schema_type}"
889
+ )
890
 
891
+ elif isinstance(schema_type, str):
892
 
893
+ if not _matches_schema_type(
894
+ value,
895
+ schema_type,
896
+ ):
897
+ return (
898
+ f"{path}: expected "
899
+ f"{schema_type}"
900
+ )
901
 
902
+ if "enum" in schema:
903
+
904
+ if value not in schema["enum"]:
905
+ return (
906
+ f"{path}: value must be one of "
907
+ f"{schema['enum']}"
908
+ )
909
+
910
+ if isinstance(value, dict):
911
+
912
+ required = schema.get(
913
+ "required",
914
+ [],
915
+ )
916
+
917
+ for field_name in required:
918
+
919
+ if field_name not in value:
920
+ return (
921
+ f"{path}: missing required "
922
+ f"field '{field_name}'"
923
+ )
924
+
925
+ properties = schema.get(
926
+ "properties",
927
+ {},
928
+ )
929
+
930
+ for field_name, field_value in value.items():
931
+
932
+ field_schema = properties.get(
933
+ field_name
934
+ )
935
+
936
+ if isinstance(field_schema, dict):
937
+
938
+ error = validate_schema(
939
+ field_value,
940
+ field_schema,
941
+ f"{path}.{field_name}",
942
+ )
943
+
944
+ if error:
945
+ return error
946
+
947
+ if (
948
+ schema.get("additionalProperties")
949
+ is False
950
+ ):
951
+ allowed = set(
952
+ properties.keys()
953
+ )
954
+
955
+ unknown = (
956
+ set(value.keys())
957
+ - allowed
958
+ )
959
+
960
+ if unknown:
961
+ return (
962
+ f"{path}: unknown fields "
963
+ f"{sorted(unknown)}"
964
+ )
965
+
966
+ if isinstance(value, list):
967
+
968
+ min_items = schema.get(
969
+ "minItems"
970
+ )
971
+
972
+ max_items = schema.get(
973
+ "maxItems"
974
+ )
975
+
976
+ if (
977
+ isinstance(min_items, int)
978
+ and len(value) < min_items
979
+ ):
980
+ return (
981
+ f"{path}: requires at least "
982
+ f"{min_items} items"
983
+ )
984
+
985
+ if (
986
+ isinstance(max_items, int)
987
+ and len(value) > max_items
988
+ ):
989
+ return (
990
+ f"{path}: allows at most "
991
+ f"{max_items} items"
992
+ )
993
+
994
+ item_schema = schema.get(
995
+ "items"
996
+ )
997
+
998
+ if isinstance(item_schema, dict):
999
+
1000
+ for index, item in enumerate(value):
1001
+
1002
+ error = validate_schema(
1003
+ item,
1004
+ item_schema,
1005
+ f"{path}[{index}]",
1006
+ )
1007
+
1008
+ if error:
1009
+ return error
1010
+
1011
+ if isinstance(value, str):
1012
+
1013
+ min_length = schema.get(
1014
+ "minLength"
1015
+ )
1016
+
1017
+ max_length = schema.get(
1018
+ "maxLength"
1019
+ )
1020
+
1021
+ if (
1022
+ isinstance(min_length, int)
1023
+ and len(value) < min_length
1024
+ ):
1025
+ return (
1026
+ f"{path}: string shorter than "
1027
+ f"{min_length}"
1028
+ )
1029
+
1030
+ if (
1031
+ isinstance(max_length, int)
1032
+ and len(value) > max_length
1033
+ ):
1034
+ return (
1035
+ f"{path}: string longer than "
1036
+ f"{max_length}"
1037
+ )
1038
+
1039
+ return None
1040
+
1041
+ # =====================================================
1042
+ # AGENT CONTRACTS
1043
+ # =====================================================
1044
+
1045
+ class ToolDecisionStatus(
1046
+ str,
1047
+ Enum,
1048
+ ):
1049
+ VALID = "valid"
1050
+ REPAIRABLE = "repairable"
1051
+ RETRY_REQUIRED = "retry_required"
1052
+ BLOCK = "block"
1053
+ NO_TOOL = "no_tool"
1054
+
1055
+
1056
+ class CanonicalToolCall(BaseModel):
1057
+ id: str
1058
+ name: str
1059
+ arguments: Dict[str, Any] = Field(
1060
+ default_factory=dict
1061
+ )
1062
+
1063
+
1064
+ class ToolDecision(BaseModel):
1065
+ status: ToolDecisionStatus
1066
+
1067
+ tool_calls: List[
1068
+ CanonicalToolCall
1069
+ ] = Field(
1070
+ default_factory=list
1071
+ )
1072
+
1073
+ reason: Optional[str] = None
1074
+
1075
+
1076
+ class ToolRegistry:
1077
+
1078
+ def __init__(
1079
  self,
1080
+ tools: Optional[
1081
+ List[Dict[str, Any]]
1082
+ ] = None,
1083
+ ):
1084
 
1085
+ self._tools: Dict[
1086
+ str,
1087
+ Dict[str, Any]
1088
+ ] = {}
1089
 
1090
+ for tool in tools or []:
 
 
 
1091
 
1092
+ if not isinstance(
1093
+ tool,
1094
+ dict,
1095
+ ):
1096
+ continue
1097
 
1098
+ if tool.get("type") != "function":
1099
+ continue
1100
 
1101
+ function = tool.get(
1102
+ "function",
1103
+ {},
1104
+ )
1105
+
1106
+ name = function.get(
1107
+ "name"
1108
+ )
1109
+
1110
+ if name:
1111
+ self._tools[name] = tool
1112
+
1113
+ def has(
1114
+ self,
1115
+ name: str,
1116
+ ) -> bool:
1117
+
1118
+ return name in self._tools
1119
+
1120
+ def schema(
1121
+ self,
1122
+ name: str,
1123
+ ) -> Dict[str, Any]:
1124
+
1125
+ tool = self._tools.get(
1126
+ name
1127
+ )
1128
+
1129
+ if not tool:
1130
+ return {}
1131
+
1132
+ return (
1133
+ tool
1134
+ .get("function", {})
1135
+ .get("parameters", {})
1136
+ )
1137
+
1138
+ def names(self) -> List[str]:
1139
+ return list(
1140
+ self._tools.keys()
1141
+ )
1142
+
1143
+
1144
+ def _extract_json_candidate(
1145
+ text: str,
1146
+ ) -> Optional[Any]:
1147
+
1148
+ if not text:
1149
+ return None
1150
+
1151
+ stripped = text.strip()
1152
+
1153
+ if "```json" in stripped:
1154
+
1155
+ try:
1156
+ stripped = (
1157
+ stripped
1158
+ .split("```json", 1)[1]
1159
+ .split("```", 1)[0]
1160
+ .strip()
1161
+ )
1162
+ except Exception:
1163
+ pass
1164
+
1165
+ try:
1166
+ return json.loads(
1167
+ stripped
1168
+ )
1169
+ except Exception:
1170
+ pass
1171
+
1172
+ decoder = json.JSONDecoder()
1173
+
1174
+ for index, character in enumerate(
1175
+ stripped
1176
+ ):
1177
+
1178
+ if character not in (
1179
+ "{",
1180
+ "[",
1181
+ ):
1182
+ continue
1183
+
1184
+ try:
1185
+ parsed, _ = decoder.raw_decode(
1186
+ stripped[index:]
1187
+ )
1188
+
1189
+ return parsed
1190
+
1191
+ except Exception:
1192
+ continue
1193
+
1194
+ return None
1195
+
1196
+
1197
+ class ToolProcessor:
1198
+
1199
+ def __init__(
1200
+ self,
1201
+ registry: ToolRegistry,
1202
+ ):
1203
+ self.registry = registry
1204
+
1205
+ def _normalize_native_calls(
1206
+ self,
1207
+ native_calls: List[Dict[str, Any]],
1208
+ ) -> Tuple[
1209
+ List[CanonicalToolCall],
1210
+ Optional[str],
1211
+ ]:
1212
+
1213
+ result = []
1214
+
1215
+ for call in native_calls:
1216
+
1217
+ call = _to_dict(
1218
+ call
1219
+ )
1220
+
1221
+ if not isinstance(
1222
+ call,
1223
+ dict,
1224
+ ):
1225
+ return (
1226
+ [],
1227
+ "Malformed native tool call",
1228
+ )
1229
+
1230
+ function = _to_dict(
1231
+ call.get("function")
1232
+ or {}
1233
+ )
1234
+
1235
+ if not isinstance(
1236
+ function,
1237
+ dict,
1238
+ ):
1239
+ return (
1240
+ [],
1241
+ "Malformed native function node",
1242
+ )
1243
+
1244
+ name = (
1245
+ function.get("name")
1246
+ or ""
1247
+ )
1248
+
1249
+ arguments = function.get(
1250
+ "arguments",
1251
+ {},
1252
+ )
1253
 
1254
+ if isinstance(
1255
+ arguments,
1256
+ str,
1257
+ ):
1258
+ try:
1259
+ arguments = json.loads(
1260
+ arguments
1261
+ )
1262
  except Exception:
1263
+ return (
1264
+ [],
1265
+ (
1266
+ f"Malformed JSON arguments "
1267
+ f"for tool '{name}'"
1268
+ ),
1269
  )
1270
 
1271
+ if not isinstance(
1272
+ arguments,
1273
+ dict,
1274
+ ):
1275
+ return (
1276
+ [],
1277
+ (
1278
+ f"Arguments for tool "
1279
+ f"'{name}' must be object"
1280
+ ),
1281
+ )
1282
+
1283
+ result.append(
1284
+ CanonicalToolCall(
1285
+ id=(
1286
+ call.get("id")
1287
+ or
1288
+ f"call_{uuid.uuid4().hex[:24]}"
1289
+ ),
1290
+ name=name,
1291
+ arguments=arguments,
1292
+ )
1293
+ )
1294
+
1295
+ return result, None
1296
 
1297
+ def _normalize_emulated_calls(
1298
+ self,
1299
+ raw_text: str,
1300
+ ) -> Tuple[
1301
+ List[CanonicalToolCall],
1302
+ Optional[str],
1303
+ ]:
1304
+
1305
+ parsed = _extract_json_candidate(
1306
+ raw_text
1307
+ )
1308
+
1309
+ if parsed is None:
1310
+ return [], None
1311
+
1312
+ calls = []
1313
 
1314
+ if isinstance(
1315
+ parsed,
1316
+ dict,
1317
+ ):
1318
 
1319
+ if isinstance(
1320
+ parsed.get("tool_calls"),
1321
+ list,
1322
+ ):
1323
+ calls = parsed[
1324
+ "tool_calls"
1325
+ ]
1326
 
1327
+ elif parsed.get("name"):
1328
+ calls = [parsed]
 
1329
 
1330
+ else:
1331
+ return [], None
1332
 
1333
+ else:
1334
+ return [], None
1335
 
1336
+ normalized = []
 
1337
 
1338
+ for index, call in enumerate(
1339
+ calls
1340
+ ):
1341
+
1342
+ if not isinstance(
1343
+ call,
1344
+ dict,
1345
+ ):
1346
+ return (
1347
+ [],
1348
+ "Malformed emulated tool call",
1349
+ )
1350
+
1351
+ function_node = call.get(
1352
+ "function"
1353
+ )
1354
+
1355
+ if isinstance(
1356
+ function_node,
1357
+ dict,
1358
+ ):
1359
+ name = (
1360
+ function_node.get("name")
1361
+ or call.get("name")
1362
+ or ""
1363
+ )
1364
+
1365
+ arguments = (
1366
+ function_node.get(
1367
+ "arguments",
1368
+ call.get(
1369
+ "arguments",
1370
+ {},
1371
+ ),
1372
  )
1373
+ )
1374
 
1375
+ else:
1376
+ name = (
1377
+ call.get("name")
1378
+ or ""
1379
+ )
1380
+
1381
+ arguments = call.get(
1382
+ "arguments",
1383
+ call.get(
1384
+ "input",
1385
+ {},
1386
+ ),
1387
+ )
1388
+
1389
+ if isinstance(
1390
+ arguments,
1391
+ str,
1392
+ ):
1393
+
1394
+ try:
1395
+ arguments = json.loads(
1396
+ arguments
1397
+ )
1398
+
1399
+ except Exception:
1400
+ return (
1401
+ [],
1402
+ (
1403
+ f"Malformed JSON arguments "
1404
+ f"for tool '{name}'"
1405
+ ),
1406
+ )
1407
+
1408
+ if not isinstance(
1409
+ arguments,
1410
+ dict,
1411
+ ):
1412
+ return (
1413
+ [],
1414
+ (
1415
+ f"Arguments for tool "
1416
+ f"'{name}' must be object"
1417
+ ),
1418
+ )
1419
+
1420
+ normalized.append(
1421
+ CanonicalToolCall(
1422
+ id=(
1423
+ call.get("id")
1424
+ or
1425
+ f"call_emu_{index}_{uuid.uuid4().hex[:16]}"
1426
+ ),
1427
+ name=name,
1428
+ arguments=arguments,
1429
+ )
1430
+ )
1431
+
1432
+ return normalized, None
1433
+
1434
+ def process(
1435
+ self,
1436
+ raw_text: Optional[str],
1437
+ native_calls: Optional[
1438
+ List[Dict[str, Any]]
1439
+ ],
1440
+ ) -> ToolDecision:
1441
+
1442
+ if native_calls:
1443
+
1444
+ calls, error = (
1445
+ self._normalize_native_calls(
1446
+ native_calls
1447
+ )
1448
+ )
1449
+
1450
+ else:
1451
+
1452
+ calls, error = (
1453
+ self._normalize_emulated_calls(
1454
+ raw_text or ""
1455
  )
1456
+ )
1457
+
1458
+ if error:
1459
+
1460
+ return ToolDecision(
1461
+ status=(
1462
+ ToolDecisionStatus
1463
+ .RETRY_REQUIRED
1464
+ ),
1465
+ reason=error,
1466
+ )
1467
 
1468
  if not calls:
1469
+
1470
  return ToolDecision(
1471
+ status=(
1472
+ ToolDecisionStatus
1473
+ .NO_TOOL
1474
+ )
1475
  )
1476
 
1477
  for call in calls:
1478
 
1479
+ if not call.name:
1480
+
1481
  return ToolDecision(
1482
+ status=(
1483
+ ToolDecisionStatus
1484
+ .RETRY_REQUIRED
1485
+ ),
1486
+ reason=(
1487
+ "Tool name is missing"
1488
+ ),
1489
  )
1490
 
1491
+ if not self.registry.has(
1492
+ call.name
1493
+ ):
1494
+
1495
  return ToolDecision(
1496
+ status=(
1497
+ ToolDecisionStatus
1498
+ .RETRY_REQUIRED
1499
+ ),
1500
+ reason=(
1501
+ f"Unknown tool "
1502
+ f"'{call.name}'"
1503
+ ),
1504
  )
1505
 
1506
+ schema = self.registry.schema(
1507
+ call.name
1508
+ )
 
1509
 
1510
+ validation_error = (
1511
+ validate_schema(
1512
+ call.arguments,
1513
+ schema,
1514
  )
1515
+ )
1516
 
1517
+ if validation_error:
1518
+
1519
+ return ToolDecision(
1520
+ status=(
1521
+ ToolDecisionStatus
1522
+ .RETRY_REQUIRED
1523
+ ),
1524
+ reason=(
1525
+ f"Tool '{call.name}' "
1526
+ f"schema violation: "
1527
+ f"{validation_error}"
1528
+ ),
1529
+ )
1530
 
1531
  return ToolDecision(
1532
+ status=(
1533
+ ToolDecisionStatus
1534
+ .VALID
1535
+ ),
1536
  tool_calls=calls,
1537
  )
1538
 
 
1546
  "دعني أتحقق",
1547
  "دعني أتأكد",
1548
  "سأحاول",
1549
+ "سأعمل على",
1550
  "سأنشئ",
1551
  "سأبحث",
1552
  "انتظر",
1553
  "لحظة من فضلك",
1554
+ "امنحني لحظة",
1555
  "let me check",
1556
  "let me verify",
1557
  "i will",
1558
  "i'll",
1559
  "hold on",
1560
  "give me a moment",
1561
+ "i am going to",
1562
+ "i'm going to",
1563
  "please wait",
1564
+ "one moment",
1565
  )
1566
 
1567
 
1568
+ def looks_like_unfulfilled_intent(
1569
+ text: Optional[str],
1570
+ ) -> bool:
1571
+
1572
  if not text:
1573
  return False
1574
 
1575
  lowered = text.strip().lower()
1576
 
1577
+ if not lowered:
1578
+ return False
1579
+
1580
  if len(lowered) > 500:
1581
  return False
1582
 
 
1585
  for pattern in _HEDGE_PATTERNS
1586
  )
1587
 
1588
+ # =====================================================
1589
+ # TOOL HISTORY SANITIZER
1590
+ # =====================================================
1591
+
1592
+ def content_to_text(
1593
+ content: Any,
1594
+ ) -> str:
1595
+
1596
+ if content is None:
1597
+ return ""
1598
+
1599
+ if isinstance(content, str):
1600
+ return content
1601
+
1602
+ if isinstance(content, list):
1603
+
1604
+ parts = []
1605
+
1606
+ for block in content:
1607
+
1608
+ if not isinstance(
1609
+ block,
1610
+ dict,
1611
+ ):
1612
+ parts.append(
1613
+ str(block)
1614
+ )
1615
+ continue
1616
+
1617
+ block_type = block.get(
1618
+ "type"
1619
+ )
1620
+
1621
+ if block_type in (
1622
+ "text",
1623
+ "input_text",
1624
+ "output_text",
1625
+ ):
1626
+ parts.append(
1627
+ str(
1628
+ block.get(
1629
+ "text",
1630
+ ""
1631
+ )
1632
+ )
1633
+ )
1634
+
1635
+ elif block_type == "tool_result":
1636
+
1637
+ parts.append(
1638
+ (
1639
+ "[Tool Result "
1640
+ f"{block.get('tool_use_id', '')}] "
1641
+ f"{block.get('content', '')}"
1642
+ )
1643
+ )
1644
+
1645
+ return "\n".join(
1646
+ part
1647
+ for part in parts
1648
+ if part
1649
+ )
1650
+
1651
+ return str(content)
1652
+
1653
+
1654
+ def sanitize_tool_history(
1655
+ messages: List[Dict[str, Any]],
1656
+ ) -> List[Dict[str, str]]:
1657
 
 
1658
  output = []
1659
 
1660
  for message in messages:
1661
+
1662
  role = message.get("role")
1663
 
1664
  if role == "tool":
1665
+
1666
  output.append({
1667
  "role": "user",
1668
  "content": (
1669
+ "[TOOL EXECUTION RESULT]\n"
1670
+ f"Tool name: {message.get('name', 'tool')}\n"
1671
+ f"Tool call id: {message.get('tool_call_id', '')}\n"
1672
+ f"Result:\n{content_to_text(message.get('content'))}\n\n"
1673
+ "Continue the task now. "
1674
+ "If another tool is required, "
1675
+ "call it immediately using the required JSON format. "
1676
+ "If the task is complete, provide the final answer."
1677
  ),
1678
  })
1679
 
1680
+ continue
1681
+
1682
+ if (
1683
  role == "assistant"
1684
  and message.get("tool_calls")
1685
  ):
1686
+
1687
  reconstructed = []
1688
 
1689
+ for tool_call in (
1690
+ message.get("tool_calls")
1691
+ or []
1692
+ ):
1693
 
1694
+ if not isinstance(
1695
+ tool_call,
1696
+ dict,
1697
+ ):
1698
+ continue
1699
 
1700
+ function = tool_call.get(
1701
+ "function",
1702
+ {},
1703
+ )
1704
+
1705
+ arguments = function.get(
1706
+ "arguments",
1707
+ {},
1708
+ )
1709
+
1710
+ if isinstance(
1711
+ arguments,
1712
+ str,
1713
+ ):
1714
+ try:
1715
+ arguments = json.loads(
1716
+ arguments
1717
+ )
1718
+ except Exception:
1719
+ arguments = {}
1720
 
1721
  reconstructed.append({
1722
+ "name": function.get(
1723
+ "name",
1724
+ "",
1725
+ ),
1726
  "arguments": arguments,
1727
  })
1728
 
1729
  output.append({
1730
  "role": "assistant",
1731
+ "content": _canonical_json({
1732
+ "tool_calls": reconstructed
1733
+ }),
 
1734
  })
1735
 
1736
+ continue
1737
+
1738
+ output.append({
1739
+ "role": (
1740
+ role
1741
+ if role in (
1742
+ "system",
1743
+ "user",
1744
+ "assistant",
1745
+ )
1746
+ else "user"
1747
+ ),
1748
+ "content": content_to_text(
1749
+ message.get("content")
1750
+ ),
1751
+ })
1752
 
1753
  return output
1754
 
1755
  # =====================================================
1756
+ # AGENT DIRECTIVE
1757
  # =====================================================
1758
 
1759
+ def build_agent_directive(
1760
+ tools: List[Dict[str, Any]],
1761
+ tool_choice: Optional[Any],
1762
+ ) -> Dict[str, str]:
1763
 
1764
+ tool_specs = []
1765
 
1766
  for tool in tools:
 
1767
 
1768
+ if not isinstance(
1769
+ tool,
1770
+ dict,
1771
+ ):
1772
+ continue
1773
+
1774
+ function = tool.get(
1775
+ "function",
1776
+ {},
1777
+ )
1778
+
1779
+ name = function.get(
1780
+ "name",
1781
+ "unknown_tool",
1782
+ )
1783
+
1784
+ description = function.get(
1785
+ "description",
1786
+ "",
1787
+ )
1788
+
1789
+ parameters = function.get(
1790
+ "parameters",
1791
+ {
1792
+ "type": "object",
1793
+ "properties": {},
1794
+ },
1795
+ )
1796
+
1797
+ tool_specs.append(
1798
+ f"TOOL: {name}\n"
1799
+ f"DESCRIPTION: {description}\n"
1800
+ f"ARGUMENT SCHEMA:\n"
1801
+ f"{json.dumps(parameters, ensure_ascii=False, indent=2)}"
1802
  )
1803
 
1804
  forced = ""
1805
 
1806
  if tool_choice == "required":
1807
+
1808
  forced = (
1809
+ "\nIMPORTANT: You MUST call "
1810
+ "at least one available tool now."
1811
  )
1812
 
1813
+ elif isinstance(
1814
+ tool_choice,
1815
+ dict,
1816
+ ):
1817
+
1818
+ forced_name = (
1819
  tool_choice
1820
  .get("function", {})
1821
  .get("name")
1822
  )
1823
 
1824
+ if forced_name:
1825
+
1826
  forced = (
1827
+ "\nIMPORTANT: You MUST call "
1828
+ f"the tool '{forced_name}' now."
1829
  )
1830
 
1831
  return {
1832
  "role": "system",
1833
  "content": (
1834
+ "You are an execution agent with access to external tools.\n"
1835
+ "You are not merely a conversational model.\n\n"
1836
+
1837
+ "When the user request requires an external action, "
1838
+ "use an available tool immediately.\n"
1839
 
1840
+ "When calling tools, output ONLY one valid JSON object "
1841
+ "and no other text before or after it.\n\n"
1842
 
1843
+ "Required format:\n"
1844
+ '{"tool_calls":[{"name":"tool_name","arguments":{"key":"value"}}]}\n\n'
1845
 
1846
+ "RULES:\n"
1847
+ "1. Never promise future execution.\n"
1848
+ "2. Never say you will check, create, search, wait, or verify "
1849
+ "without actually calling a tool.\n"
1850
+ "3. Tool names and arguments must exactly match the schemas.\n"
1851
+ "4. Multiple tools may be requested in tool_calls when appropriate.\n"
1852
+ "5. After receiving a tool result, decide immediately whether another "
1853
+ "tool call is required or whether the final answer can be returned.\n"
1854
+ "6. If no tool is needed, return the complete final answer as normal text.\n\n"
1855
 
1856
  "AVAILABLE TOOLS:\n\n"
1857
+ + "\n\n".join(
1858
+ tool_specs
1859
+ )
1860
  + forced
1861
  ),
1862
  }
1863
 
1864
 
1865
  def build_agent_reminder(
1866
+ tools: List[Dict[str, Any]],
1867
+ tool_choice: Optional[Any],
1868
+ retry_note: Optional[str] = None,
1869
+ ) -> Dict[str, str]:
1870
 
1871
+ names = []
1872
+
1873
+ for tool in tools:
1874
+
1875
+ if not isinstance(
1876
+ tool,
1877
+ dict,
1878
+ ):
1879
+ continue
1880
+
1881
+ name = (
1882
+ tool
1883
+ .get("function", {})
1884
+ .get("name")
1885
+ )
1886
+
1887
+ if name:
1888
+ names.append(name)
1889
 
1890
  forced = ""
1891
 
1892
  if tool_choice == "required":
 
1893
 
1894
+ forced = (
1895
+ "\nAt least one tool call "
1896
+ "is mandatory in this turn."
1897
+ )
1898
+
1899
+ elif isinstance(
1900
+ tool_choice,
1901
+ dict,
1902
+ ):
1903
+
1904
+ forced_name = (
1905
  tool_choice
1906
  .get("function", {})
1907
  .get("name")
1908
  )
1909
 
1910
+ if forced_name:
1911
+
1912
  forced = (
1913
+ f"\nCall '{forced_name}' now."
1914
  )
1915
 
1916
  retry = ""
1917
 
1918
  if retry_note:
1919
+
1920
+ retry = (
1921
+ "\nPREVIOUS OUTPUT ERROR: "
1922
+ f"{retry_note}\n"
1923
+ "Correct the error now."
1924
+ )
1925
 
1926
  return {
1927
  "role": "system",
1928
  "content": (
1929
+ "IMMEDIATE AGENT REMINDER:\n"
1930
+ f"Available tools: {', '.join(names)}.\n"
1931
+ "If execution is required, output ONLY "
1932
+ '{"tool_calls":[...]} as valid JSON.\n'
1933
+ "If execution is not required, return the complete final answer now.\n"
1934
+ "Do not promise future action."
 
 
 
1935
  + forced
1936
  + retry
1937
  ),
 
1946
  content: Any = None
1947
  tool_call_id: Optional[str] = None
1948
  name: Optional[str] = None
1949
+ tool_calls: Optional[
1950
+ List[Dict[str, Any]]
1951
+ ] = None
1952
 
1953
 
1954
  class ChatRequest(BaseModel):
1955
  model: str = "claude-sonnet-4-6"
1956
  messages: List[ChatMessage]
1957
+
1958
  stream: bool = False
1959
 
1960
  temperature: Optional[float] = 0.7
1961
  max_tokens: Optional[int] = None
1962
 
1963
+ tools: Optional[
1964
+ List[Dict[str, Any]]
1965
+ ] = None
1966
+
1967
  tool_choice: Optional[Any] = None
1968
 
1969
+ user: Optional[str] = None
1970
+
1971
  conversation_id: Optional[str] = None
1972
 
1973
  # =====================================================
1974
  # AUTH
1975
  # =====================================================
1976
 
1977
+ def verify(
1978
+ request: Request,
1979
+ ):
1980
 
1981
  auth = request.headers.get(
1982
  "Authorization",
 
1987
  auth.startswith("Bearer ")
1988
  and auth[7:] == API_KEY
1989
  ):
1990
+ return True
1991
 
1992
  if (
1993
+ request.headers.get(
1994
+ "X-API-Key",
1995
+ "",
1996
+ )
1997
  == API_KEY
1998
  ):
1999
+ return True
2000
 
2001
  raise HTTPException(
2002
  status_code=401,
2003
+ detail=(
2004
+ "Invalid or missing API key"
2005
+ ),
2006
+ )
2007
+
2008
+ # =====================================================
2009
+ # CONVERSATION HELPERS
2010
+ # =====================================================
2011
+
2012
+ def get_conversation_id(
2013
+ body: ChatRequest,
2014
+ request: Request,
2015
+ ) -> str:
2016
+
2017
+ return (
2018
+ body.conversation_id
2019
+ or request.headers.get(
2020
+ "X-Conversation-ID"
2021
+ )
2022
+ or body.user
2023
+ or "default"
2024
+ )
2025
+
2026
+
2027
+ def merge_memory_and_incoming(
2028
+ stored: List[Dict[str, Any]],
2029
+ incoming: List[Dict[str, Any]],
2030
+ ) -> List[Dict[str, Any]]:
2031
+
2032
+ if not stored:
2033
+ return incoming
2034
+
2035
+ if not incoming:
2036
+ return stored
2037
+
2038
+ stored_hashes = {
2039
+ _message_hash({
2040
+ key: value
2041
+ for key, value in message.items()
2042
+ if value is not None
2043
+ })
2044
+ for message in stored
2045
+ }
2046
+
2047
+ merged = list(stored)
2048
+
2049
+ for message in incoming:
2050
+
2051
+ normalized = {
2052
+ key: value
2053
+ for key, value in message.items()
2054
+ if value is not None
2055
+ }
2056
+
2057
+ digest = _message_hash(
2058
+ normalized
2059
+ )
2060
+
2061
+ if digest not in stored_hashes:
2062
+ merged.append(
2063
+ normalized
2064
+ )
2065
+ stored_hashes.add(
2066
+ digest
2067
+ )
2068
+
2069
+ return merged
2070
+
2071
+
2072
+ def usage_payload(
2073
+ prompt_text: str,
2074
+ completion_text: str,
2075
+ ) -> Dict[str, int]:
2076
+
2077
+ prompt_tokens = (
2078
+ len(prompt_text) // 4
2079
+ if prompt_text
2080
+ else 0
2081
+ )
2082
+
2083
+ completion_tokens = (
2084
+ len(completion_text) // 4
2085
+ if completion_text
2086
+ else 0
2087
  )
2088
 
2089
+ return {
2090
+ "prompt_tokens": prompt_tokens,
2091
+ "completion_tokens": completion_tokens,
2092
+ "total_tokens": (
2093
+ prompt_tokens
2094
+ + completion_tokens
2095
+ ),
2096
+ }
2097
+
2098
  # =====================================================
2099
  # APP
2100
  # =====================================================
2101
 
2102
  app = FastAPI(
2103
+ title="G4F Qwen OpenAI-Compatible Agent Gateway",
2104
+ version="1.0.0",
2105
  )
2106
 
2107
  # =====================================================
2108
  # ROOT
2109
  # =====================================================
2110
 
2111
+ @app.api_route(
2112
+ "/",
2113
+ methods=["GET", "HEAD"],
2114
+ )
2115
  async def root():
2116
+
2117
+ return JSONResponse({
2118
  "status": "running",
2119
  "provider": "Qwen",
2120
  "cookies": COOKIE_STATUS,
2121
  "memory": "SQLite",
2122
+ "api": "OpenAI Chat Completions",
2123
+ })
2124
 
2125
  # =====================================================
2126
  # MODELS
2127
  # =====================================================
2128
 
2129
  @app.get("/v1/models")
2130
+ async def list_models(
2131
+ request: Request,
2132
+ ):
2133
 
2134
  verify(request)
2135
 
2136
+ return JSONResponse({
2137
  "object": "list",
2138
  "data": [
2139
  {
2140
+ "id": model,
2141
  "object": "model",
2142
+ "created": 0,
2143
  "owned_by": "qwen",
2144
  }
2145
+ for model in PUBLIC_MODELS
2146
  ],
2147
+ })
2148
 
2149
  # =====================================================
2150
  # CHAT COMPLETIONS
 
2158
 
2159
  verify(request)
2160
 
2161
+ if not body.messages:
2162
+
2163
+ raise HTTPException(
2164
+ status_code=400,
2165
+ detail="messages required",
2166
+ )
2167
+
2168
  request_id = (
2169
+ "chatcmpl-"
2170
+ + uuid.uuid4().hex[:24]
2171
+ )
2172
+
2173
+ created = int(
2174
+ time.time()
2175
  )
2176
 
2177
+ conversation_id = (
2178
+ get_conversation_id(
2179
+ body,
2180
+ request,
2181
+ )
2182
+ )
2183
 
2184
  incoming_messages = [
2185
+ message.model_dump(
2186
+ exclude_none=True
2187
+ )
2188
  for message in body.messages
2189
  ]
2190
 
2191
+ stored_memory = await load_memory(
2192
+ conversation_id
 
 
2193
  )
2194
 
2195
+ merged_messages = (
2196
+ merge_memory_and_incoming(
2197
+ stored_memory,
2198
+ incoming_messages,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2199
  )
2200
+ )
2201
 
2202
+ await save_messages(
2203
+ conversation_id,
2204
+ incoming_messages,
2205
+ )
 
 
 
 
2206
 
2207
  # =================================================
2208
+ # MODEL MODE
2209
+ # No tools -> preserve original streaming behavior.
2210
  # =================================================
2211
 
2212
  if not body.tools:
 
2214
  last_user_index = None
2215
 
2216
  for index in range(
2217
+ len(merged_messages) - 1,
2218
  -1,
2219
  -1,
2220
  ):
2221
+
2222
  if (
2223
+ merged_messages[index]
2224
+ .get("role")
2225
  == "user"
2226
  ):
2227
  last_user_index = index
2228
  break
2229
 
2230
  if last_user_index is None:
2231
+
2232
  raise HTTPException(
2233
  status_code=400,
2234
+ detail=(
2235
+ "At least one user "
2236
+ "message is required"
2237
+ ),
2238
+ )
2239
+
2240
+ last_user_message = (
2241
+ merged_messages[
2242
+ last_user_index
2243
+ ]
2244
+ )
2245
+
2246
+ last_user_text = (
2247
+ content_to_text(
2248
+ last_user_message.get(
2249
+ "content"
2250
+ )
2251
  )
2252
+ )
2253
 
2254
+ history = (
2255
+ merged_messages[
2256
+ :last_user_index
2257
+ ]
2258
  )
2259
 
2260
+ # =============================================
2261
+ # MODEL MODE STREAM
2262
+ # =============================================
2263
 
2264
  if body.stream:
2265
 
2266
+ async def generate_model_stream():
2267
 
2268
  queue = asyncio.Queue()
2269
+ loop = (
2270
+ asyncio.get_running_loop()
2271
+ )
2272
 
2273
  def producer():
2274
+
2275
  try:
2276
+
2277
+ for text in ask(
2278
+ last_user_text,
2279
  history,
2280
  "Qwen",
2281
  body.model,
2282
  ):
2283
+
2284
  asyncio.run_coroutine_threadsafe(
2285
+ queue.put(
2286
+ (
2287
+ "chunk",
2288
+ text,
2289
+ )
2290
+ ),
2291
  loop,
2292
  )
2293
 
2294
  except Exception as e:
2295
+
2296
  asyncio.run_coroutine_threadsafe(
2297
+ queue.put(
2298
+ (
2299
+ "error",
2300
+ e,
2301
+ )
2302
+ ),
2303
  loop,
2304
  )
2305
 
2306
  finally:
2307
+
2308
  asyncio.run_coroutine_threadsafe(
2309
+ queue.put(
2310
+ (
2311
+ "done",
2312
+ None,
2313
+ )
2314
+ ),
2315
  loop,
2316
  )
2317
 
 
2320
  producer,
2321
  )
2322
 
2323
+ full_text = ""
2324
 
2325
  while True:
2326
 
2327
+ event_type, value = (
2328
+ await queue.get()
2329
+ )
2330
 
2331
+ if event_type == "chunk":
2332
 
2333
+ full_text += value
2334
 
2335
+ chunk = {
2336
  "id": request_id,
2337
+ "object": (
2338
+ "chat.completion.chunk"
2339
+ ),
2340
  "created": created,
2341
  "model": body.model,
2342
  "choices": [{
 
2351
  yield (
2352
  "data: "
2353
  + json.dumps(
2354
+ chunk,
2355
  ensure_ascii=False,
2356
  )
2357
  + "\n\n"
2358
  )
2359
 
2360
+ elif event_type == "error":
2361
+
2362
+ error_chunk = {
2363
+ "error": {
2364
+ "message": str(
2365
+ value
2366
+ ),
2367
+ "type": (
2368
+ "upstream_error"
2369
+ ),
2370
+ }
2371
+ }
2372
 
2373
  yield (
2374
  "data: "
2375
+ + json.dumps(
2376
+ error_chunk,
2377
+ ensure_ascii=False,
2378
+ )
 
2379
  + "\n\n"
2380
  )
2381
 
2382
+ elif event_type == "done":
2383
  break
2384
 
2385
+ if full_text:
2386
+
2387
+ await save_message(
2388
  conversation_id,
2389
  {
2390
  "role": "assistant",
2391
+ "content": full_text,
2392
  },
2393
  )
2394
 
2395
  final_chunk = {
2396
  "id": request_id,
2397
+ "object": (
2398
+ "chat.completion.chunk"
2399
+ ),
2400
  "created": created,
2401
  "model": body.model,
2402
  "choices": [{
 
2408
 
2409
  yield (
2410
  "data: "
2411
+ + json.dumps(
2412
+ final_chunk,
2413
+ ensure_ascii=False,
2414
+ )
2415
  + "\n\n"
2416
  )
2417
 
2418
  yield "data: [DONE]\n\n"
2419
 
2420
  return StreamingResponse(
2421
+ generate_model_stream(),
2422
+ media_type=(
2423
+ "text/event-stream"
2424
+ ),
2425
  headers={
2426
+ "Cache-Control": (
2427
+ "no-cache"
2428
+ ),
2429
+ "Connection": (
2430
+ "keep-alive"
2431
+ ),
2432
+ "X-Accel-Buffering": (
2433
+ "no"
2434
+ ),
2435
  },
2436
  )
2437
 
2438
+ # =============================================
2439
+ # MODEL MODE NON-STREAM
2440
+ # =============================================
2441
+
2442
+ try:
2443
+
2444
+ response_text, _ = (
2445
+ await call_qwen_full_messages_async(
2446
+ [
2447
+ {
2448
+ "role": (
2449
+ message.get(
2450
+ "role",
2451
+ "user",
2452
+ )
2453
+ ),
2454
+ "content": (
2455
+ content_to_text(
2456
+ message.get(
2457
+ "content"
2458
+ )
2459
+ )
2460
+ ),
2461
+ }
2462
+ for message in merged_messages
2463
+ if message.get("role")
2464
+ in (
2465
+ "system",
2466
+ "user",
2467
+ "assistant",
2468
+ )
2469
+ ],
2470
+ body.model,
2471
+ )
2472
+ )
2473
+
2474
+ except Exception as e:
2475
+
2476
+ raise HTTPException(
2477
+ status_code=502,
2478
+ detail=(
2479
+ f"Qwen failed: {e}"
2480
+ ),
2481
+ )
2482
 
2483
  assistant_message = {
2484
  "role": "assistant",
2485
  "content": response_text,
2486
  }
2487
 
2488
+ await save_message(
2489
  conversation_id,
2490
  assistant_message,
2491
  )
2492
 
2493
+ prompt_text = "\n".join(
2494
+ content_to_text(
2495
+ message.get("content")
2496
+ )
2497
+ for message in merged_messages
2498
+ )
2499
+
2500
+ return JSONResponse({
2501
  "id": request_id,
2502
  "object": "chat.completion",
2503
  "created": created,
 
2506
  "index": 0,
2507
  "message": assistant_message,
2508
  "finish_reason": "stop",
2509
+ "logprobs": None,
2510
  }],
2511
+ "usage": usage_payload(
2512
+ prompt_text,
2513
+ response_text,
2514
+ ),
2515
+ })
 
2516
 
2517
  # =================================================
2518
  # AGENT MODE
2519
+ # tools present -> full-message agent path.
2520
  # =================================================
2521
 
2522
+ registry = ToolRegistry(
2523
+ body.tools
2524
+ )
2525
+
2526
+ processor = ToolProcessor(
2527
+ registry
2528
+ )
2529
 
2530
+ sanitized_history = (
2531
+ sanitize_tool_history(
2532
+ merged_messages
2533
+ )
2534
  )
2535
 
2536
  agent_messages = (
2537
+ [
2538
+ build_agent_directive(
2539
+ body.tools,
2540
+ body.tool_choice,
2541
+ )
2542
+ ]
2543
+ + sanitized_history
2544
+ + [
2545
+ build_agent_reminder(
2546
+ body.tools,
2547
+ body.tool_choice,
2548
+ )
2549
+ ]
2550
  )
2551
 
2552
+ runtime_attempts = 0
2553
 
2554
  while True:
2555
 
2556
  try:
2557
+
2558
+ (
2559
+ response_text,
2560
+ native_tool_calls,
2561
+ ) = (
2562
+ await call_qwen_full_messages_async(
2563
  agent_messages,
2564
  body.model,
2565
  )
2566
  )
2567
 
2568
  except Exception as e:
2569
+
2570
  raise HTTPException(
2571
  status_code=502,
2572
+ detail=(
2573
+ f"Qwen failed: {e}"
2574
+ ),
2575
  )
2576
 
2577
  decision = processor.process(
2578
  raw_text=response_text,
2579
+ native_calls=native_tool_calls,
2580
  )
2581
 
2582
  # =============================================
2583
+ # INVALID TOOL CALL -> RETRY
2584
  # =============================================
2585
 
2586
  if (
 
2588
  == ToolDecisionStatus.RETRY_REQUIRED
2589
  ):
2590
 
2591
+ runtime_attempts += 1
2592
+
2593
+ if (
2594
+ runtime_attempts
2595
+ > MAX_TOOL_RETRIES
2596
+ ):
2597
 
 
2598
  raise HTTPException(
2599
  status_code=502,
2600
  detail=(
2601
+ "Qwen failed to produce "
2602
+ "a valid tool call after "
2603
+ "bounded retries."
2604
  ),
2605
  )
2606
 
2607
  agent_messages.append({
2608
  "role": "assistant",
2609
+ "content": (
2610
+ response_text
2611
+ or "[Invalid tool call]"
2612
+ ),
2613
  })
2614
 
2615
  agent_messages.append(
2616
  build_agent_reminder(
2617
  body.tools,
2618
  body.tool_choice,
2619
+ retry_note=(
2620
+ decision.reason
2621
+ or
2622
+ "Invalid tool call."
2623
+ ),
2624
  )
2625
  )
2626
 
2627
  continue
2628
 
2629
  # =============================================
2630
+ # HEDGING DETECTION -> RETRY
2631
  # =============================================
2632
 
2633
  if (
2634
  decision.status
2635
  == ToolDecisionStatus.NO_TOOL
2636
+ and runtime_attempts
2637
+ < MAX_TOOL_RETRIES
2638
  and looks_like_unfulfilled_intent(
2639
  response_text
2640
  )
 
2641
  ):
2642
 
2643
+ runtime_attempts += 1
2644
 
2645
  agent_messages.append({
2646
  "role": "assistant",
 
2652
  body.tools,
2653
  body.tool_choice,
2654
  retry_note=(
2655
+ "You promised or delayed "
2656
+ "execution without calling "
2657
+ "a tool. Execute now using "
2658
+ "tool_calls JSON, or return "
2659
+ "the complete final answer."
2660
  ),
2661
  )
2662
  )
 
2665
 
2666
  # =============================================
2667
  # VALID TOOL CALLS
2668
+ # Return to OpenAI client for execution.
2669
  # =============================================
2670
 
2671
  if (
 
2677
  and decision.tool_calls
2678
  ):
2679
 
2680
+ openai_tool_calls = [
2681
  {
2682
  "id": call.id,
2683
  "type": "function",
 
2686
  "arguments": json.dumps(
2687
  call.arguments,
2688
  ensure_ascii=False,
2689
+ separators=(
2690
+ ",",
2691
+ ":",
2692
+ ),
2693
  ),
2694
  },
2695
  }
2696
+ for call
2697
+ in decision.tool_calls
2698
  ]
2699
 
2700
  assistant_message = {
2701
  "role": "assistant",
2702
  "content": None,
2703
+ "tool_calls": (
2704
+ openai_tool_calls
2705
+ ),
2706
  }
2707
 
2708
+ await save_message(
2709
  conversation_id,
2710
  assistant_message,
2711
  )
2712
 
2713
+ prompt_text = "\n".join(
2714
+ content_to_text(
2715
+ message.get(
2716
+ "content"
2717
+ )
2718
+ )
2719
+ for message
2720
+ in merged_messages
2721
+ )
2722
+
2723
+ return JSONResponse({
2724
  "id": request_id,
2725
  "object": "chat.completion",
2726
  "created": created,
2727
  "model": body.model,
2728
  "choices": [{
2729
  "index": 0,
2730
+ "message": (
2731
+ assistant_message
2732
+ ),
2733
+ "finish_reason": (
2734
+ "tool_calls"
2735
+ ),
2736
+ "logprobs": None,
2737
  }],
2738
+ "usage": usage_payload(
2739
+ prompt_text,
2740
+ response_text,
2741
+ ),
2742
+ })
 
2743
 
2744
  # =============================================
2745
  # FINAL ANSWER
 
2750
  "content": response_text,
2751
  }
2752
 
2753
+ await save_message(
2754
  conversation_id,
2755
  assistant_message,
2756
  )
2757
 
2758
+ prompt_text = "\n".join(
2759
+ content_to_text(
2760
+ message.get("content")
2761
+ )
2762
+ for message in merged_messages
2763
+ )
2764
+
2765
+ return JSONResponse({
2766
  "id": request_id,
2767
  "object": "chat.completion",
2768
  "created": created,
 
2771
  "index": 0,
2772
  "message": assistant_message,
2773
  "finish_reason": "stop",
2774
+ "logprobs": None,
2775
  }],
2776
+ "usage": usage_payload(
2777
+ prompt_text,
2778
+ response_text,
2779
+ ),
2780
+ })
 
 
 
 
 
 
2781
 
2782
  # =====================================================
2783
  # RUN
 
2798
  app,
2799
  host="0.0.0.0",
2800
  port=port,
2801
+ log_level=os.getenv(
2802
+ "LOG_LEVEL",
2803
+ "INFO",
2804
+ ).lower(),
2805
  )