patdev commited on
Commit
f12ce4d
·
verified ·
1 Parent(s): 8139a2a

vision + protocole: images, thinking adaptive, tool_choice none, plafond de sortie

Browse files
Files changed (1) hide show
  1. anthropic_proxy.py +68 -5
anthropic_proxy.py CHANGED
@@ -25,6 +25,10 @@ from fastapi.responses import JSONResponse, StreamingResponse
25
  UPSTREAM = os.environ.get("VL_UPSTREAM", "http://127.0.0.1:8080")
26
  MODEL = os.environ.get("VL_SERVED_NAME", "qwen")
27
  TIMEOUT = float(os.environ.get("VL_TIMEOUT", "1800"))
 
 
 
 
28
 
29
  # Claude Code refuse tout identifiant de modele qui ne commence pas par
30
  # "claude-" : il valide le nom avant d'emettre la requete. On expose donc des
@@ -133,6 +137,33 @@ def guard_message(detail: str) -> str:
133
 
134
  # --------------------------------------------------------------- Anthropic -> OpenAI
135
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  def _text_of(content: Any) -> str:
137
  """Anthropic autorise une chaine ou une liste de blocs typés."""
138
  if isinstance(content, str):
@@ -186,13 +217,17 @@ def to_openai(body: dict) -> dict:
186
  # Un tour d'assistant peut melanger du texte et des tool_use ; un tour
187
  # d'utilisateur porte les tool_result. OpenAI separe les deux en
188
  # `tool_calls` sur l'assistant et en messages de role `tool`.
189
- texts, calls, results = [], [], []
190
  for b in content:
191
  if not isinstance(b, dict):
192
  continue
193
  t = b.get("type")
194
  if t == "text":
195
  texts.append(b.get("text", ""))
 
 
 
 
196
  elif t == "tool_use":
197
  calls.append({
198
  "id": b.get("id") or f"call_{uuid.uuid4().hex[:8]}",
@@ -203,10 +238,21 @@ def to_openai(body: dict) -> dict:
203
  },
204
  })
205
  elif t == "tool_result":
 
 
 
 
 
 
 
 
 
 
 
206
  results.append({
207
  "role": "tool",
208
  "tool_call_id": b.get("tool_use_id", ""),
209
- "content": _text_of(b.get("content")) or "",
210
  })
211
 
212
  if role == "assistant":
@@ -215,7 +261,16 @@ def to_openai(body: dict) -> dict:
215
  a["tool_calls"] = calls
216
  msgs.append(a)
217
  else:
218
- if texts:
 
 
 
 
 
 
 
 
 
219
  msgs.append({"role": "user", "content": "".join(texts)})
220
  msgs.extend(results)
221
  else:
@@ -224,7 +279,7 @@ def to_openai(body: dict) -> dict:
224
  out: dict[str, Any] = {
225
  "model": MODEL,
226
  "messages": msgs,
227
- "max_tokens": body.get("max_tokens", 4096),
228
  "stream": bool(body.get("stream")),
229
  }
230
  for src, dst in (("temperature", "temperature"), ("top_p", "top_p"),
@@ -239,6 +294,12 @@ def to_openai(body: dict) -> dict:
239
  # sous la forme enable_thinking=false, qui fait prefixer un bloc <think> deja
240
  # ferme. Sans cette traduction, le champ etait recu puis ignore en silence :
241
  # la case "raisonnement" de la console ne changeait rien.
 
 
 
 
 
 
242
  th = body.get("thinking")
243
  if isinstance(th, dict) and th.get("type") == "disabled":
244
  out["chat_template_kwargs"] = {"enable_thinking": False}
@@ -254,7 +315,9 @@ def to_openai(body: dict) -> dict:
254
  } for t in body["tools"] if t.get("name")]
255
  tc = body.get("tool_choice") or {}
256
  kind = tc.get("type") if isinstance(tc, dict) else None
257
- if kind == "any":
 
 
258
  out["tool_choice"] = "required"
259
  elif kind == "tool" and tc.get("name"):
260
  out["tool_choice"] = {"type": "function", "function": {"name": tc["name"]}}
 
25
  UPSTREAM = os.environ.get("VL_UPSTREAM", "http://127.0.0.1:8080")
26
  MODEL = os.environ.get("VL_SERVED_NAME", "qwen")
27
  TIMEOUT = float(os.environ.get("VL_TIMEOUT", "1800"))
28
+ # Sortie maximale du modele. Claude Code demande couramment 64 k, ce que
29
+ # vLLM refuse d'un 400 portant sur max_tokens -- la requete entiere echoue
30
+ # alors qu'un plafonnement silencieux suffit.
31
+ MAX_OUTPUT = int(os.environ.get("VL_MAX_OUTPUT", "32768"))
32
 
33
  # Claude Code refuse tout identifiant de modele qui ne commence pas par
34
  # "claude-" : il valide le nom avant d'emettre la requete. On expose donc des
 
137
 
138
  # --------------------------------------------------------------- Anthropic -> OpenAI
139
 
140
+ def _image_part(block: dict) -> dict | None:
141
+ """Traduit un bloc `image` Anthropic vers la partie `image_url` d'OpenAI.
142
+
143
+ Anthropic decrit l'image par une `source` typee : `base64` porte les octets
144
+ et le type MIME separement, `url` porte un lien. OpenAI attend dans les deux
145
+ cas UNE chaine dans `image_url.url` -- une URI de donnees pour le premier,
146
+ le lien tel quel pour le second.
147
+
148
+ Sans cette traduction, `_text_of` ignorait purement et simplement les blocs
149
+ image : une capture collee dans Claude Code arrivait au modele comme un
150
+ message vide, et le modele repondait a cote sans que rien ne signale la
151
+ perte.
152
+ """
153
+ src = block.get("source") or {}
154
+ kind = src.get("type")
155
+ if kind == "base64":
156
+ data = src.get("data")
157
+ if not data:
158
+ return None
159
+ mime = src.get("media_type") or "image/png"
160
+ return {"type": "image_url",
161
+ "image_url": {"url": f"data:{mime};base64,{data}"}}
162
+ if kind == "url" and src.get("url"):
163
+ return {"type": "image_url", "image_url": {"url": src["url"]}}
164
+ return None
165
+
166
+
167
  def _text_of(content: Any) -> str:
168
  """Anthropic autorise une chaine ou une liste de blocs typés."""
169
  if isinstance(content, str):
 
217
  # Un tour d'assistant peut melanger du texte et des tool_use ; un tour
218
  # d'utilisateur porte les tool_result. OpenAI separe les deux en
219
  # `tool_calls` sur l'assistant et en messages de role `tool`.
220
+ texts, calls, results, images = [], [], [], []
221
  for b in content:
222
  if not isinstance(b, dict):
223
  continue
224
  t = b.get("type")
225
  if t == "text":
226
  texts.append(b.get("text", ""))
227
+ elif t == "image":
228
+ part = _image_part(b)
229
+ if part:
230
+ images.append(part)
231
  elif t == "tool_use":
232
  calls.append({
233
  "id": b.get("id") or f"call_{uuid.uuid4().hex[:8]}",
 
238
  },
239
  })
240
  elif t == "tool_result":
241
+ # Un resultat d'outil peut porter des images (capture rendue
242
+ # par un outil). Le role `tool` d'OpenAI n'accepte que du
243
+ # texte : on extrait les images pour les rattacher au tour
244
+ # utilisateur, sinon elles disparaissent en silence.
245
+ rc = b.get("content")
246
+ if isinstance(rc, list):
247
+ for sub in rc:
248
+ if isinstance(sub, dict) and sub.get("type") == "image":
249
+ part = _image_part(sub)
250
+ if part:
251
+ images.append(part)
252
  results.append({
253
  "role": "tool",
254
  "tool_call_id": b.get("tool_use_id", ""),
255
+ "content": _text_of(rc) or "",
256
  })
257
 
258
  if role == "assistant":
 
261
  a["tool_calls"] = calls
262
  msgs.append(a)
263
  else:
264
+ if images:
265
+ # Contenu multipart : OpenAI n'accepte les images que dans
266
+ # une LISTE de parties, jamais dans une chaine.
267
+ parts: list[dict] = []
268
+ joined = "".join(texts)
269
+ if joined:
270
+ parts.append({"type": "text", "text": joined})
271
+ parts.extend(images)
272
+ msgs.append({"role": "user", "content": parts})
273
+ elif texts:
274
  msgs.append({"role": "user", "content": "".join(texts)})
275
  msgs.extend(results)
276
  else:
 
279
  out: dict[str, Any] = {
280
  "model": MODEL,
281
  "messages": msgs,
282
+ "max_tokens": min(int(body.get("max_tokens") or 4096), MAX_OUTPUT),
283
  "stream": bool(body.get("stream")),
284
  }
285
  for src, dst in (("temperature", "temperature"), ("top_p", "top_p"),
 
294
  # sous la forme enable_thinking=false, qui fait prefixer un bloc <think> deja
295
  # ferme. Sans cette traduction, le champ etait recu puis ignore en silence :
296
  # la case "raisonnement" de la console ne changeait rien.
297
+ # `thinking` a plusieurs formes. La documentation du protocole de passerelle
298
+ # precise que Claude Code envoie `{"type": "adaptive"}` aux modeles recents
299
+ # ET "traite les noms de modeles qu'il ne reconnait pas, tels les alias de
300
+ # passerelle, comme des modeles actuels qui recoivent le champ" -- donc nous.
301
+ # Seul "disabled" doit couper le raisonnement ; "adaptive" et "enabled" le
302
+ # laissent actif, qui est le defaut du gabarit.
303
  th = body.get("thinking")
304
  if isinstance(th, dict) and th.get("type") == "disabled":
305
  out["chat_template_kwargs"] = {"enable_thinking": False}
 
315
  } for t in body["tools"] if t.get("name")]
316
  tc = body.get("tool_choice") or {}
317
  kind = tc.get("type") if isinstance(tc, dict) else None
318
+ if kind == "none":
319
+ out["tool_choice"] = "none"
320
+ elif kind == "any":
321
  out["tool_choice"] = "required"
322
  elif kind == "tool" and tc.get("name"):
323
  out["tool_choice"] = {"type": "function", "function": {"name": tc["name"]}}