copilotx 2.3.0__tar.gz → 2.3.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {copilotx-2.3.0 → copilotx-2.3.2}/.gitignore +2 -0
- {copilotx-2.3.0 → copilotx-2.3.2}/PKG-INFO +4 -3
- {copilotx-2.3.0 → copilotx-2.3.2}/README.md +2 -1
- {copilotx-2.3.0 → copilotx-2.3.2}/deploy/nginx-copilotx-http.conf +3 -0
- {copilotx-2.3.0 → copilotx-2.3.2}/deploy/nginx-copilotx.conf +2 -2
- {copilotx-2.3.0 → copilotx-2.3.2}/pyproject.toml +2 -2
- {copilotx-2.3.0 → copilotx-2.3.2}/src/copilotx/__init__.py +1 -1
- {copilotx-2.3.0 → copilotx-2.3.2}/src/copilotx/proxy/client.py +49 -4
- {copilotx-2.3.0 → copilotx-2.3.2}/src/copilotx/proxy/translator.py +65 -13
- {copilotx-2.3.0 → copilotx-2.3.2}/src/copilotx/server/app.py +24 -0
- {copilotx-2.3.0 → copilotx-2.3.2}/src/copilotx/server/routes_anthropic.py +20 -9
- {copilotx-2.3.0 → copilotx-2.3.2}/src/copilotx/server/routes_openai.py +21 -9
- {copilotx-2.3.0 → copilotx-2.3.2}/src/copilotx/server/routes_responses.py +15 -9
- copilotx-2.3.0/test_remote.sh +0 -137
- copilotx-2.3.0/tests/test_tools_translation.py +0 -469
- {copilotx-2.3.0 → copilotx-2.3.2}/LICENSE +0 -0
- {copilotx-2.3.0 → copilotx-2.3.2}/deploy/.env.example +0 -0
- {copilotx-2.3.0 → copilotx-2.3.2}/deploy/Caddyfile +0 -0
- {copilotx-2.3.0 → copilotx-2.3.2}/deploy/copilotx-azureuser.service +0 -0
- {copilotx-2.3.0 → copilotx-2.3.2}/deploy/copilotx.service +0 -0
- {copilotx-2.3.0 → copilotx-2.3.2}/src/copilotx/__main__.py +0 -0
- {copilotx-2.3.0 → copilotx-2.3.2}/src/copilotx/auth/__init__.py +0 -0
- {copilotx-2.3.0 → copilotx-2.3.2}/src/copilotx/auth/oauth.py +0 -0
- {copilotx-2.3.0 → copilotx-2.3.2}/src/copilotx/auth/storage.py +0 -0
- {copilotx-2.3.0 → copilotx-2.3.2}/src/copilotx/auth/token.py +0 -0
- {copilotx-2.3.0 → copilotx-2.3.2}/src/copilotx/cli.py +0 -0
- {copilotx-2.3.0 → copilotx-2.3.2}/src/copilotx/config.py +0 -0
- {copilotx-2.3.0 → copilotx-2.3.2}/src/copilotx/proxy/__init__.py +0 -0
- {copilotx-2.3.0 → copilotx-2.3.2}/src/copilotx/proxy/responses_stream.py +0 -0
- {copilotx-2.3.0 → copilotx-2.3.2}/src/copilotx/proxy/streaming.py +0 -0
- {copilotx-2.3.0 → copilotx-2.3.2}/src/copilotx/server/__init__.py +0 -0
- {copilotx-2.3.0 → copilotx-2.3.2}/src/copilotx/server/routes_models.py +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: copilotx
|
|
3
|
-
Version: 2.3.
|
|
3
|
+
Version: 2.3.2
|
|
4
4
|
Summary: Local GitHub Copilot API proxy — use GPT-4o, Claude, Gemini via OpenAI/Anthropic compatible APIs
|
|
5
5
|
Project-URL: Homepage, https://github.com/Polly2014/CopilotX
|
|
6
6
|
Project-URL: Repository, https://github.com/Polly2014/CopilotX
|
|
7
7
|
Project-URL: Issues, https://github.com/Polly2014/CopilotX/issues
|
|
8
|
-
Author-email: Polly <
|
|
8
|
+
Author-email: Polly <im@polly.wang>
|
|
9
9
|
License: MIT
|
|
10
10
|
License-File: LICENSE
|
|
11
11
|
Keywords: anthropic,copilot,llm,openai,proxy
|
|
@@ -369,7 +369,8 @@ client = OpenAI(
|
|
|
369
369
|
| v1.0.0 | Local | OAuth, dual API, streaming, model discovery |
|
|
370
370
|
| v2.0.0 | Remote | API key auth, remote deploy, Nginx/Caddy + systemd templates |
|
|
371
371
|
| v2.1.0 | Codex | Responses API, vision support, dynamic API URL, stream ID sync |
|
|
372
|
-
|
|
|
372
|
+
| v2.2.0 | Config | `copilotx config` command for client setup (Claude Code) |
|
|
373
|
+
| **v2.3.x** | **Polish** | **Error passthrough, stream error handling, test suite** |
|
|
373
374
|
| v3.0.0 | Multi-User | Token pool, user database, OpenRouter mode |
|
|
374
375
|
|
|
375
376
|
## ⚠️ Disclaimer
|
|
@@ -344,7 +344,8 @@ client = OpenAI(
|
|
|
344
344
|
| v1.0.0 | Local | OAuth, dual API, streaming, model discovery |
|
|
345
345
|
| v2.0.0 | Remote | API key auth, remote deploy, Nginx/Caddy + systemd templates |
|
|
346
346
|
| v2.1.0 | Codex | Responses API, vision support, dynamic API URL, stream ID sync |
|
|
347
|
-
|
|
|
347
|
+
| v2.2.0 | Config | `copilotx config` command for client setup (Claude Code) |
|
|
348
|
+
| **v2.3.x** | **Polish** | **Error passthrough, stream error handling, test suite** |
|
|
348
349
|
| v3.0.0 | Multi-User | Token pool, user database, OpenRouter mode |
|
|
349
350
|
|
|
350
351
|
## ⚠️ Disclaimer
|
|
@@ -9,6 +9,9 @@ server {
|
|
|
9
9
|
listen 80;
|
|
10
10
|
listen [::]:80;
|
|
11
11
|
server_name YOUR_DOMAIN;
|
|
12
|
+
|
|
13
|
+
# Allow large request bodies for long conversations (Claude 200K context can exceed 16MB)
|
|
14
|
+
client_max_body_size 32M;
|
|
12
15
|
|
|
13
16
|
# Let's Encrypt challenge
|
|
14
17
|
location /.well-known/acme-challenge/ {
|
|
@@ -38,8 +38,8 @@ server {
|
|
|
38
38
|
include /etc/letsencrypt/options-ssl-nginx.conf;
|
|
39
39
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
|
40
40
|
|
|
41
|
-
# Allow large request bodies for long
|
|
42
|
-
client_max_body_size
|
|
41
|
+
# Allow large request bodies for long conversations (Claude 200K context can exceed 16MB)
|
|
42
|
+
client_max_body_size 32M;
|
|
43
43
|
|
|
44
44
|
# Security headers
|
|
45
45
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "copilotx"
|
|
3
|
-
version = "2.3.
|
|
3
|
+
version = "2.3.2"
|
|
4
4
|
description = "Local GitHub Copilot API proxy — use GPT-4o, Claude, Gemini via OpenAI/Anthropic compatible APIs"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { text = "MIT" }
|
|
7
7
|
requires-python = ">=3.10"
|
|
8
|
-
authors = [{ name = "Polly", email = "
|
|
8
|
+
authors = [{ name = "Polly", email = "im@polly.wang" }]
|
|
9
9
|
keywords = ["copilot", "llm", "proxy", "openai", "anthropic"]
|
|
10
10
|
classifiers = [
|
|
11
11
|
"Development Status :: 5 - Production/Stable",
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
import logging
|
|
6
7
|
import time
|
|
7
8
|
from typing import Any, AsyncIterator
|
|
8
9
|
|
|
@@ -18,6 +19,8 @@ from copilotx.config import (
|
|
|
18
19
|
REQUEST_TIMEOUT,
|
|
19
20
|
)
|
|
20
21
|
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
21
24
|
|
|
22
25
|
class CopilotClient:
|
|
23
26
|
"""Async client that talks to the Copilot API (dynamic base URL)."""
|
|
@@ -87,7 +90,17 @@ class CopilotClient:
|
|
|
87
90
|
assert self._client is not None
|
|
88
91
|
url = f"{self._api_base}{COPILOT_CHAT_COMPLETIONS_PATH}"
|
|
89
92
|
resp = await self._client.post(url, json=payload, headers=self._headers())
|
|
90
|
-
resp.
|
|
93
|
+
if resp.status_code >= 400:
|
|
94
|
+
error_body = resp.text
|
|
95
|
+
logger.error(
|
|
96
|
+
"Chat completions error: status=%d body=%s",
|
|
97
|
+
resp.status_code, error_body[:1000],
|
|
98
|
+
)
|
|
99
|
+
raise httpx.HTTPStatusError(
|
|
100
|
+
f"HTTP {resp.status_code}: {error_body[:500]}",
|
|
101
|
+
request=resp.request,
|
|
102
|
+
response=resp,
|
|
103
|
+
)
|
|
91
104
|
return resp.json()
|
|
92
105
|
|
|
93
106
|
# ── Chat Completions (streaming) ────────────────────────────────
|
|
@@ -101,7 +114,17 @@ class CopilotClient:
|
|
|
101
114
|
async with self._client.stream(
|
|
102
115
|
"POST", url, json=payload, headers=self._headers(),
|
|
103
116
|
) as resp:
|
|
104
|
-
resp.
|
|
117
|
+
if resp.status_code >= 400:
|
|
118
|
+
error_body = await resp.aread()
|
|
119
|
+
logger.error(
|
|
120
|
+
"Chat completions stream error: status=%d body=%s",
|
|
121
|
+
resp.status_code, error_body[:1000],
|
|
122
|
+
)
|
|
123
|
+
raise httpx.HTTPStatusError(
|
|
124
|
+
f"HTTP {resp.status_code}: {error_body.decode('utf-8', errors='replace')[:500]}",
|
|
125
|
+
request=resp.request,
|
|
126
|
+
response=resp,
|
|
127
|
+
)
|
|
105
128
|
async for line in resp.aiter_lines():
|
|
106
129
|
if line:
|
|
107
130
|
yield (line + "\n").encode("utf-8")
|
|
@@ -124,10 +147,22 @@ class CopilotClient:
|
|
|
124
147
|
# Strip service_tier — not supported by GitHub Copilot
|
|
125
148
|
payload.pop("service_tier", None)
|
|
126
149
|
|
|
150
|
+
logger.debug("Responses API request: url=%s payload_keys=%s", url, list(payload.keys()))
|
|
151
|
+
|
|
127
152
|
resp = await self._client.post(
|
|
128
153
|
url, json=payload, headers=self._headers(extra_headers),
|
|
129
154
|
)
|
|
130
|
-
resp.
|
|
155
|
+
if resp.status_code >= 400:
|
|
156
|
+
error_body = resp.text
|
|
157
|
+
logger.error(
|
|
158
|
+
"Responses API error: status=%d url=%s body=%s",
|
|
159
|
+
resp.status_code, url, error_body[:1000],
|
|
160
|
+
)
|
|
161
|
+
raise httpx.HTTPStatusError(
|
|
162
|
+
f"HTTP {resp.status_code}: {error_body[:500]}",
|
|
163
|
+
request=resp.request,
|
|
164
|
+
response=resp,
|
|
165
|
+
)
|
|
131
166
|
return resp.json()
|
|
132
167
|
|
|
133
168
|
# ── Responses API (streaming) ───────────────────────────────────
|
|
@@ -150,7 +185,17 @@ class CopilotClient:
|
|
|
150
185
|
async with self._client.stream(
|
|
151
186
|
"POST", url, json=payload, headers=self._headers(extra_headers),
|
|
152
187
|
) as resp:
|
|
153
|
-
resp.
|
|
188
|
+
if resp.status_code >= 400:
|
|
189
|
+
error_body = await resp.aread()
|
|
190
|
+
logger.error(
|
|
191
|
+
"Responses stream error: status=%d url=%s body=%s",
|
|
192
|
+
resp.status_code, url, error_body[:1000],
|
|
193
|
+
)
|
|
194
|
+
raise httpx.HTTPStatusError(
|
|
195
|
+
f"HTTP {resp.status_code}: {error_body.decode('utf-8', errors='replace')[:500]}",
|
|
196
|
+
request=resp.request,
|
|
197
|
+
response=resp,
|
|
198
|
+
)
|
|
154
199
|
async for line in resp.aiter_lines():
|
|
155
200
|
if line:
|
|
156
201
|
yield (line + "\n").encode("utf-8")
|
|
@@ -381,11 +381,47 @@ def openai_to_anthropic_response(openai_resp: dict, model: str) -> dict:
|
|
|
381
381
|
"""Convert an OpenAI chat completion response to Anthropic /v1/messages format.
|
|
382
382
|
|
|
383
383
|
Handles text content, tool_calls, and mixed responses.
|
|
384
|
+
|
|
385
|
+
IMPORTANT: Copilot backend may split text and tool_calls into separate choices:
|
|
386
|
+
choices[0] = {"message": {"content": "text..."}, "finish_reason": "tool_calls"}
|
|
387
|
+
choices[1] = {"message": {"tool_calls": [...]}, "finish_reason": "tool_calls"}
|
|
388
|
+
We must merge ALL choices to build the complete Anthropic response.
|
|
384
389
|
"""
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
390
|
+
choices = openai_resp.get("choices", [{}])
|
|
391
|
+
|
|
392
|
+
# Merge content and tool_calls from ALL choices
|
|
393
|
+
# (Copilot backend splits them into separate choices)
|
|
394
|
+
content_text = ""
|
|
395
|
+
all_tool_calls: list[dict] = []
|
|
396
|
+
finish_reason = "end_turn"
|
|
397
|
+
|
|
398
|
+
for choice in choices:
|
|
399
|
+
message = choice.get("message", {})
|
|
400
|
+
|
|
401
|
+
# Collect text content
|
|
402
|
+
text = message.get("content")
|
|
403
|
+
if text:
|
|
404
|
+
if content_text:
|
|
405
|
+
content_text += "\n" + text
|
|
406
|
+
else:
|
|
407
|
+
content_text = text
|
|
408
|
+
|
|
409
|
+
# Collect tool_calls
|
|
410
|
+
tc_list = message.get("tool_calls")
|
|
411
|
+
if tc_list:
|
|
412
|
+
all_tool_calls.extend(tc_list)
|
|
413
|
+
|
|
414
|
+
# Use the most specific finish_reason
|
|
415
|
+
fr = choice.get("finish_reason")
|
|
416
|
+
if fr == "tool_calls":
|
|
417
|
+
finish_reason = "tool_calls"
|
|
418
|
+
elif fr and finish_reason not in ("tool_calls",):
|
|
419
|
+
finish_reason = fr
|
|
420
|
+
|
|
421
|
+
logger.debug(
|
|
422
|
+
"OpenAI response: %d choices, text=%d chars, tool_calls=%d, finish=%s",
|
|
423
|
+
len(choices), len(content_text), len(all_tool_calls), finish_reason,
|
|
424
|
+
)
|
|
389
425
|
|
|
390
426
|
# Build content blocks
|
|
391
427
|
content_blocks: list[dict[str, Any]] = []
|
|
@@ -395,8 +431,8 @@ def openai_to_anthropic_response(openai_resp: dict, model: str) -> dict:
|
|
|
395
431
|
content_blocks.append({"type": "text", "text": content_text})
|
|
396
432
|
|
|
397
433
|
# Convert OpenAI tool_calls → Anthropic tool_use blocks
|
|
398
|
-
if
|
|
399
|
-
for tc in
|
|
434
|
+
if all_tool_calls:
|
|
435
|
+
for tc in all_tool_calls:
|
|
400
436
|
func = tc.get("function", {})
|
|
401
437
|
# Parse arguments JSON string → dict
|
|
402
438
|
try:
|
|
@@ -416,7 +452,6 @@ def openai_to_anthropic_response(openai_resp: dict, model: str) -> dict:
|
|
|
416
452
|
content_blocks.append({"type": "text", "text": ""})
|
|
417
453
|
|
|
418
454
|
# Map finish_reason
|
|
419
|
-
finish_reason = choice.get("finish_reason", "end_turn")
|
|
420
455
|
stop_reason_map = {
|
|
421
456
|
"stop": "end_turn",
|
|
422
457
|
"length": "max_tokens",
|
|
@@ -516,12 +551,29 @@ async def openai_stream_to_anthropic_stream(
|
|
|
516
551
|
yield _sse_event("message_start", start_event)
|
|
517
552
|
sent_start = True
|
|
518
553
|
|
|
519
|
-
# Extract delta
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
554
|
+
# Extract delta from ALL choices (Copilot may split text/tool_calls
|
|
555
|
+
# into separate choices with different indices)
|
|
556
|
+
content = None
|
|
557
|
+
tool_calls = None
|
|
558
|
+
chunk_finish = None
|
|
559
|
+
|
|
560
|
+
for choice in chunk.get("choices", []):
|
|
561
|
+
delta = choice.get("delta", {})
|
|
562
|
+
|
|
563
|
+
# Collect text content from any choice
|
|
564
|
+
c = delta.get("content")
|
|
565
|
+
if c:
|
|
566
|
+
content = c
|
|
567
|
+
|
|
568
|
+
# Collect tool_calls from any choice
|
|
569
|
+
tc = delta.get("tool_calls")
|
|
570
|
+
if tc:
|
|
571
|
+
tool_calls = tc
|
|
572
|
+
|
|
573
|
+
# Track finish reason from any choice
|
|
574
|
+
fr = choice.get("finish_reason")
|
|
575
|
+
if fr:
|
|
576
|
+
chunk_finish = fr
|
|
525
577
|
|
|
526
578
|
# Track finish reason
|
|
527
579
|
if chunk_finish:
|
|
@@ -6,6 +6,7 @@ from contextlib import asynccontextmanager
|
|
|
6
6
|
from typing import AsyncIterator
|
|
7
7
|
|
|
8
8
|
from fastapi import FastAPI, Request, Response
|
|
9
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
9
10
|
from fastapi.responses import JSONResponse
|
|
10
11
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
11
12
|
|
|
@@ -15,6 +16,16 @@ from copilotx.config import COPILOTX_API_KEY, LOCALHOST_ADDRS, PUBLIC_PATHS
|
|
|
15
16
|
from copilotx.proxy.client import CopilotClient
|
|
16
17
|
|
|
17
18
|
|
|
19
|
+
# ── CORS Configuration ──────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
CORS_ORIGINS = [
|
|
22
|
+
"https://polly.wang",
|
|
23
|
+
"https://www.polly.wang",
|
|
24
|
+
"http://127.0.0.1:1111", # Zola dev server
|
|
25
|
+
"http://localhost:1111", # Zola dev server (localhost)
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
18
29
|
# ── API Key Middleware ──────────────────────────────────────────────
|
|
19
30
|
|
|
20
31
|
|
|
@@ -30,6 +41,10 @@ class ApiKeyMiddleware(BaseHTTPMiddleware):
|
|
|
30
41
|
"""
|
|
31
42
|
|
|
32
43
|
async def dispatch(self, request: Request, call_next) -> Response:
|
|
44
|
+
# CORS preflight requests must pass through (handled by CORSMiddleware)
|
|
45
|
+
if request.method == "OPTIONS":
|
|
46
|
+
return await call_next(request)
|
|
47
|
+
|
|
33
48
|
# No API key configured → fully open (local mode)
|
|
34
49
|
if not COPILOTX_API_KEY:
|
|
35
50
|
return await call_next(request)
|
|
@@ -107,6 +122,15 @@ def create_app(token_manager: TokenManager) -> FastAPI:
|
|
|
107
122
|
)
|
|
108
123
|
app.state.token_manager = token_manager
|
|
109
124
|
|
|
125
|
+
# Add CORS middleware (must be before other middlewares)
|
|
126
|
+
app.add_middleware(
|
|
127
|
+
CORSMiddleware,
|
|
128
|
+
allow_origins=CORS_ORIGINS,
|
|
129
|
+
allow_credentials=True,
|
|
130
|
+
allow_methods=["*"],
|
|
131
|
+
allow_headers=["*"],
|
|
132
|
+
)
|
|
133
|
+
|
|
110
134
|
# Add API key middleware
|
|
111
135
|
app.add_middleware(ApiKeyMiddleware)
|
|
112
136
|
|
|
@@ -6,6 +6,7 @@ the Copilot backend speaks), and translates responses back.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import json
|
|
9
10
|
import logging
|
|
10
11
|
|
|
11
12
|
from fastapi import APIRouter, Request
|
|
@@ -63,13 +64,23 @@ async def messages(request: Request):
|
|
|
63
64
|
return JSONResponse(content=anthropic_resp)
|
|
64
65
|
except Exception as e:
|
|
65
66
|
logger.error("Copilot backend error: %s", e)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
"
|
|
71
|
-
|
|
72
|
-
"message": f"Copilot backend error: {e}",
|
|
73
|
-
},
|
|
67
|
+
status_code = 502
|
|
68
|
+
error_content = {
|
|
69
|
+
"type": "error",
|
|
70
|
+
"error": {
|
|
71
|
+
"type": "upstream_error",
|
|
72
|
+
"message": f"Copilot backend error: {e}",
|
|
74
73
|
},
|
|
75
|
-
|
|
74
|
+
}
|
|
75
|
+
if hasattr(e, 'response') and e.response is not None:
|
|
76
|
+
status_code = e.response.status_code
|
|
77
|
+
try:
|
|
78
|
+
# Try to parse backend JSON error and extract message
|
|
79
|
+
backend_error = json.loads(e.response.text)
|
|
80
|
+
if "error" in backend_error:
|
|
81
|
+
error_content["error"]["message"] = backend_error["error"].get("message", str(backend_error["error"]))
|
|
82
|
+
else:
|
|
83
|
+
error_content["error"]["message"] = e.response.text[:500]
|
|
84
|
+
except (json.JSONDecodeError, ValueError):
|
|
85
|
+
error_content["error"]["message"] = e.response.text[:500]
|
|
86
|
+
return JSONResponse(status_code=status_code, content=error_content)
|
|
@@ -6,12 +6,17 @@ the Copilot backend already speaks OpenAI format.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
|
|
9
12
|
from fastapi import APIRouter, Request
|
|
10
13
|
from fastapi.responses import JSONResponse
|
|
11
14
|
|
|
12
15
|
from copilotx.proxy.streaming import sse_response
|
|
13
16
|
from copilotx.server.app import get_ready_client
|
|
14
17
|
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
15
20
|
router = APIRouter(tags=["OpenAI"])
|
|
16
21
|
|
|
17
22
|
|
|
@@ -31,12 +36,19 @@ async def chat_completions(request: Request):
|
|
|
31
36
|
result = await client.chat_completions(body)
|
|
32
37
|
return JSONResponse(content=result)
|
|
33
38
|
except Exception as e:
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
)
|
|
39
|
+
logger.error("Chat completions error: %s", e)
|
|
40
|
+
status_code = 502
|
|
41
|
+
error_content = {
|
|
42
|
+
"error": {
|
|
43
|
+
"message": f"Copilot backend error: {e}",
|
|
44
|
+
"type": "upstream_error",
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if hasattr(e, 'response') and e.response is not None:
|
|
48
|
+
status_code = e.response.status_code
|
|
49
|
+
try:
|
|
50
|
+
# Try to parse and forward the backend's JSON error
|
|
51
|
+
error_content = json.loads(e.response.text)
|
|
52
|
+
except (json.JSONDecodeError, ValueError):
|
|
53
|
+
error_content["error"]["message"] = e.response.text[:500]
|
|
54
|
+
return JSONResponse(status_code=status_code, content=error_content)
|
|
@@ -57,15 +57,21 @@ async def responses(request: Request):
|
|
|
57
57
|
return JSONResponse(content=result)
|
|
58
58
|
except Exception as e:
|
|
59
59
|
logger.error("Responses API error: %s", e)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
"
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
60
|
+
status_code = 502
|
|
61
|
+
error_content = {
|
|
62
|
+
"error": {
|
|
63
|
+
"message": f"Copilot backend error: {e}",
|
|
64
|
+
"type": "upstream_error",
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if hasattr(e, 'response') and e.response is not None:
|
|
68
|
+
status_code = e.response.status_code
|
|
69
|
+
try:
|
|
70
|
+
# Try to parse and forward the backend's JSON error
|
|
71
|
+
error_content = json.loads(e.response.text)
|
|
72
|
+
except (json.JSONDecodeError, ValueError):
|
|
73
|
+
error_content["error"]["message"] = e.response.text[:500]
|
|
74
|
+
return JSONResponse(status_code=status_code, content=error_content)
|
|
69
75
|
|
|
70
76
|
|
|
71
77
|
# ═══════════════════════════════════════════════════════════════════
|
copilotx-2.3.0/test_remote.sh
DELETED
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
#!/bin/bash
|
|
2
|
-
# CopilotX v2.1.0 远程服务完整测试脚本
|
|
3
|
-
|
|
4
|
-
set -e
|
|
5
|
-
|
|
6
|
-
API_KEY=$(grep COPILOTX_API_KEY ~/.copilotx/.env | cut -d= -f2)
|
|
7
|
-
BASE_URL="https://api.polly.wang"
|
|
8
|
-
|
|
9
|
-
echo "╔══════════════════════════════════════════════════════════════╗"
|
|
10
|
-
echo "║ CopilotX v2.1.0 远程服务完整测试 ║"
|
|
11
|
-
echo "╚══════════════════════════════════════════════════════════════╝"
|
|
12
|
-
echo ""
|
|
13
|
-
|
|
14
|
-
# 1. 健康检查
|
|
15
|
-
echo "=== 1. 健康检查 ==="
|
|
16
|
-
curl -s $BASE_URL/health | python3 -m json.tool
|
|
17
|
-
echo ""
|
|
18
|
-
|
|
19
|
-
# 2. API Key 保护
|
|
20
|
-
echo "=== 2. API Key 保护测试 ==="
|
|
21
|
-
echo -n " 无 Key 访问: "
|
|
22
|
-
RESULT=$(curl -s $BASE_URL/v1/models)
|
|
23
|
-
if echo "$RESULT" | grep -q "error"; then
|
|
24
|
-
echo "❌ 被拒绝 ✓ (预期行为)"
|
|
25
|
-
else
|
|
26
|
-
echo "⚠️ 未保护"
|
|
27
|
-
fi
|
|
28
|
-
echo ""
|
|
29
|
-
|
|
30
|
-
# 3. 模型列表
|
|
31
|
-
echo "=== 3. 模型列表 ==="
|
|
32
|
-
curl -s $BASE_URL/v1/models -H "Authorization: Bearer $API_KEY" | python3 -c "
|
|
33
|
-
import sys,json
|
|
34
|
-
d=json.load(sys.stdin)
|
|
35
|
-
print(f' ✅ 获取 {len(d[\"data\"])} 个模型')
|
|
36
|
-
for m in d['data'][:6]:
|
|
37
|
-
print(f\" - {m['id']} ({m['owned_by']})\")
|
|
38
|
-
print(' ...')
|
|
39
|
-
"
|
|
40
|
-
echo ""
|
|
41
|
-
|
|
42
|
-
# 4. Chat Completions 非流式
|
|
43
|
-
echo "=== 4. OpenAI Chat Completions (非流式) ==="
|
|
44
|
-
curl -s $BASE_URL/v1/chat/completions \
|
|
45
|
-
-H "Authorization: Bearer $API_KEY" \
|
|
46
|
-
-H "Content-Type: application/json" \
|
|
47
|
-
-d '{"model": "gpt-4o", "messages": [{"role": "user", "content": "Reply exactly: Hello CopilotX!"}], "max_tokens": 15}' | python3 -c "
|
|
48
|
-
import sys,json
|
|
49
|
-
d=json.load(sys.stdin)
|
|
50
|
-
if 'choices' in d:
|
|
51
|
-
print(f\" ✅ GPT-4o: {d['choices'][0]['message']['content']}\")
|
|
52
|
-
else:
|
|
53
|
-
print(f\" ❌ 错误: {d}\")
|
|
54
|
-
"
|
|
55
|
-
echo ""
|
|
56
|
-
|
|
57
|
-
# 5. Chat Completions 流式
|
|
58
|
-
echo "=== 5. OpenAI Chat Completions (流式) ==="
|
|
59
|
-
echo -n " ✅ 流式: "
|
|
60
|
-
curl -sN $BASE_URL/v1/chat/completions \
|
|
61
|
-
-H "Authorization: Bearer $API_KEY" \
|
|
62
|
-
-H "Content-Type: application/json" \
|
|
63
|
-
-d '{"model": "gpt-4o", "messages": [{"role": "user", "content": "Count 1,2,3,4,5"}], "stream": true, "max_tokens": 20}' 2>/dev/null | \
|
|
64
|
-
grep -oP '"content":"[^"]*"' | head -8 | sed 's/"content":"//g; s/"//g' | tr -d '\n'
|
|
65
|
-
echo ""
|
|
66
|
-
echo ""
|
|
67
|
-
|
|
68
|
-
# 6. Anthropic Messages
|
|
69
|
-
echo "=== 6. Anthropic /v1/messages ==="
|
|
70
|
-
curl -s $BASE_URL/v1/messages \
|
|
71
|
-
-H "Authorization: Bearer $API_KEY" \
|
|
72
|
-
-H "Content-Type: application/json" \
|
|
73
|
-
-d '{"model": "claude-sonnet-4", "max_tokens": 25, "messages": [{"role": "user", "content": "Say hello in French"}]}' | python3 -c "
|
|
74
|
-
import sys,json
|
|
75
|
-
d=json.load(sys.stdin)
|
|
76
|
-
if 'content' in d:
|
|
77
|
-
print(f\" ✅ Claude: {d['content'][0]['text']}\")
|
|
78
|
-
else:
|
|
79
|
-
print(f\" ❌ 错误: {d}\")
|
|
80
|
-
"
|
|
81
|
-
echo ""
|
|
82
|
-
|
|
83
|
-
# 7. Responses API 非流式
|
|
84
|
-
echo "=== 7. Responses API (非流式) ==="
|
|
85
|
-
echo " 注意: 仅 GPT-5 系列支持 Responses API"
|
|
86
|
-
curl -s $BASE_URL/v1/responses \
|
|
87
|
-
-H "Authorization: Bearer $API_KEY" \
|
|
88
|
-
-H "Content-Type: application/json" \
|
|
89
|
-
-d '{"model": "gpt-5-mini", "input": "2+2=?"}' | python3 -c "
|
|
90
|
-
import sys,json
|
|
91
|
-
d=json.load(sys.stdin)
|
|
92
|
-
if 'output' in d:
|
|
93
|
-
for item in d['output']:
|
|
94
|
-
if item.get('type') == 'message':
|
|
95
|
-
for c in item.get('content', []):
|
|
96
|
-
if c.get('type') == 'output_text':
|
|
97
|
-
print(f\" ✅ Responses: {c.get('text', '')[:80]}\")
|
|
98
|
-
break
|
|
99
|
-
break
|
|
100
|
-
else:
|
|
101
|
-
print(f\" ✅ Responses: {str(d['output'])[:80]}\")
|
|
102
|
-
elif 'error' in d:
|
|
103
|
-
print(f\" ❌ 错误: {d['error']}\")
|
|
104
|
-
else:
|
|
105
|
-
print(f\" ⚠️ 响应: {str(d)[:100]}\")
|
|
106
|
-
"
|
|
107
|
-
echo ""
|
|
108
|
-
|
|
109
|
-
# 8. Responses API 流式
|
|
110
|
-
echo "=== 8. Responses API (流式) ==="
|
|
111
|
-
echo -n " ✅ 事件类型: "
|
|
112
|
-
curl -sN $BASE_URL/v1/responses \
|
|
113
|
-
-H "Authorization: Bearer $API_KEY" \
|
|
114
|
-
-H "Content-Type: application/json" \
|
|
115
|
-
-d '{"model": "gpt-5-mini", "input": "Hi", "stream": true}' 2>/dev/null | \
|
|
116
|
-
head -8 | grep -oP '"type":"[^"]*"' | sort -u | head -4 | tr '\n' ' '
|
|
117
|
-
echo ""
|
|
118
|
-
echo ""
|
|
119
|
-
|
|
120
|
-
# 9. x-api-key 认证
|
|
121
|
-
echo "=== 9. x-api-key 认证 ==="
|
|
122
|
-
curl -s $BASE_URL/v1/models -H "x-api-key: $API_KEY" | python3 -c "
|
|
123
|
-
import sys,json
|
|
124
|
-
d=json.load(sys.stdin)
|
|
125
|
-
print(f\" ✅ x-api-key 认证成功\" if 'data' in d else f\" ❌ 失败\")
|
|
126
|
-
"
|
|
127
|
-
echo ""
|
|
128
|
-
|
|
129
|
-
# 10. SSL 证书
|
|
130
|
-
echo "=== 10. SSL 证书 ==="
|
|
131
|
-
echo | openssl s_client -connect api.polly.wang:443 -servername api.polly.wang 2>/dev/null | \
|
|
132
|
-
openssl x509 -noout -dates -issuer 2>/dev/null | sed 's/^/ /'
|
|
133
|
-
echo ""
|
|
134
|
-
|
|
135
|
-
echo "╔══════════════════════════════════════════════════════════════╗"
|
|
136
|
-
echo "║ ✅ 测试完成 ║"
|
|
137
|
-
echo "╚══════════════════════════════════════════════════════════════╝"
|
|
@@ -1,469 +0,0 @@
|
|
|
1
|
-
"""Tests for Anthropic ↔ OpenAI tools translation.
|
|
2
|
-
|
|
3
|
-
Verifies that CopilotX correctly translates:
|
|
4
|
-
1. Anthropic tool definitions → OpenAI tools/functions
|
|
5
|
-
2. Anthropic tool_use blocks → OpenAI tool_calls
|
|
6
|
-
3. Anthropic tool_result blocks → OpenAI tool messages
|
|
7
|
-
4. OpenAI tool_calls response → Anthropic tool_use content blocks
|
|
8
|
-
5. OpenAI tool_calls streaming → Anthropic tool_use SSE events
|
|
9
|
-
6. tool_choice conversion
|
|
10
|
-
"""
|
|
11
|
-
|
|
12
|
-
import asyncio
|
|
13
|
-
import json
|
|
14
|
-
|
|
15
|
-
from copilotx.proxy.translator import (
|
|
16
|
-
_convert_anthropic_tool_choice,
|
|
17
|
-
_convert_anthropic_tools,
|
|
18
|
-
anthropic_to_openai_request,
|
|
19
|
-
openai_stream_to_anthropic_stream,
|
|
20
|
-
openai_to_anthropic_response,
|
|
21
|
-
)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
# ═══════════════════════════════════════════════════════════════════
|
|
25
|
-
# 1. Tools definitions conversion
|
|
26
|
-
# ═══════════════════════════════════════════════════════════════════
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def test_tools_definition_conversion():
|
|
30
|
-
"""Anthropic tools → OpenAI tools."""
|
|
31
|
-
anthropic_tools = [
|
|
32
|
-
{
|
|
33
|
-
"name": "read_file",
|
|
34
|
-
"description": "Read a file from disk",
|
|
35
|
-
"input_schema": {
|
|
36
|
-
"type": "object",
|
|
37
|
-
"properties": {
|
|
38
|
-
"path": {"type": "string", "description": "File path"},
|
|
39
|
-
},
|
|
40
|
-
"required": ["path"],
|
|
41
|
-
},
|
|
42
|
-
},
|
|
43
|
-
{
|
|
44
|
-
"name": "write_file",
|
|
45
|
-
"description": "Write content to a file",
|
|
46
|
-
"input_schema": {
|
|
47
|
-
"type": "object",
|
|
48
|
-
"properties": {
|
|
49
|
-
"path": {"type": "string"},
|
|
50
|
-
"content": {"type": "string"},
|
|
51
|
-
},
|
|
52
|
-
"required": ["path", "content"],
|
|
53
|
-
},
|
|
54
|
-
},
|
|
55
|
-
]
|
|
56
|
-
|
|
57
|
-
result = _convert_anthropic_tools(anthropic_tools)
|
|
58
|
-
|
|
59
|
-
assert len(result) == 2
|
|
60
|
-
assert result[0]["type"] == "function"
|
|
61
|
-
assert result[0]["function"]["name"] == "read_file"
|
|
62
|
-
assert result[0]["function"]["description"] == "Read a file from disk"
|
|
63
|
-
assert result[0]["function"]["parameters"]["required"] == ["path"]
|
|
64
|
-
|
|
65
|
-
assert result[1]["function"]["name"] == "write_file"
|
|
66
|
-
assert len(result[1]["function"]["parameters"]["required"]) == 2
|
|
67
|
-
|
|
68
|
-
print("✅ Tools definition conversion: PASSED")
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
# ═══════════════════════════════════════════════════════════════════
|
|
72
|
-
# 2. tool_choice conversion
|
|
73
|
-
# ═══════════════════════════════════════════════════════════════════
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def test_tool_choice_conversion():
|
|
77
|
-
"""Anthropic tool_choice → OpenAI tool_choice."""
|
|
78
|
-
# Auto
|
|
79
|
-
assert _convert_anthropic_tool_choice({"type": "auto"}) == "auto"
|
|
80
|
-
assert _convert_anthropic_tool_choice("auto") == "auto"
|
|
81
|
-
|
|
82
|
-
# Any → required
|
|
83
|
-
assert _convert_anthropic_tool_choice({"type": "any"}) == "required"
|
|
84
|
-
assert _convert_anthropic_tool_choice("any") == "required"
|
|
85
|
-
|
|
86
|
-
# None
|
|
87
|
-
assert _convert_anthropic_tool_choice({"type": "none"}) == "none"
|
|
88
|
-
assert _convert_anthropic_tool_choice("none") == "none"
|
|
89
|
-
|
|
90
|
-
# Specific tool
|
|
91
|
-
result = _convert_anthropic_tool_choice({"type": "tool", "name": "read_file"})
|
|
92
|
-
assert result == {"type": "function", "function": {"name": "read_file"}}
|
|
93
|
-
|
|
94
|
-
print("✅ tool_choice conversion: PASSED")
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
# ═══════════════════════════════════════════════════════════════════
|
|
98
|
-
# 3. Full request translation with tools
|
|
99
|
-
# ═══════════════════════════════════════════════════════════════════
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def test_full_request_with_tools():
|
|
103
|
-
"""Complete Anthropic request with tools → OpenAI format."""
|
|
104
|
-
anthropic_request = {
|
|
105
|
-
"model": "claude-sonnet-4",
|
|
106
|
-
"max_tokens": 4096,
|
|
107
|
-
"system": "You are a helpful assistant.",
|
|
108
|
-
"tools": [
|
|
109
|
-
{
|
|
110
|
-
"name": "read_file",
|
|
111
|
-
"description": "Read a file",
|
|
112
|
-
"input_schema": {
|
|
113
|
-
"type": "object",
|
|
114
|
-
"properties": {"path": {"type": "string"}},
|
|
115
|
-
"required": ["path"],
|
|
116
|
-
},
|
|
117
|
-
}
|
|
118
|
-
],
|
|
119
|
-
"tool_choice": {"type": "auto"},
|
|
120
|
-
"messages": [
|
|
121
|
-
{"role": "user", "content": "Read the file /tmp/test.txt"},
|
|
122
|
-
],
|
|
123
|
-
"stream": True,
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
result = anthropic_to_openai_request(anthropic_request)
|
|
127
|
-
|
|
128
|
-
assert result["model"] == "claude-sonnet-4"
|
|
129
|
-
assert result["max_tokens"] == 4096
|
|
130
|
-
assert result["stream"] is True
|
|
131
|
-
assert len(result["messages"]) == 2 # system + user
|
|
132
|
-
assert "tools" in result
|
|
133
|
-
assert len(result["tools"]) == 1
|
|
134
|
-
assert result["tools"][0]["type"] == "function"
|
|
135
|
-
assert result["tools"][0]["function"]["name"] == "read_file"
|
|
136
|
-
assert result["tool_choice"] == "auto"
|
|
137
|
-
|
|
138
|
-
print("✅ Full request with tools: PASSED")
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
# ═══════════════════════════════════════════════════════════════════
|
|
142
|
-
# 4. tool_use / tool_result message conversion
|
|
143
|
-
# ═══════════════════════════════════════════════════════════════════
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
def test_tool_use_message_conversion():
|
|
147
|
-
"""Assistant message with tool_use → OpenAI assistant with tool_calls."""
|
|
148
|
-
anthropic_request = {
|
|
149
|
-
"model": "claude-sonnet-4",
|
|
150
|
-
"max_tokens": 4096,
|
|
151
|
-
"messages": [
|
|
152
|
-
{"role": "user", "content": "Read /tmp/test.txt"},
|
|
153
|
-
{
|
|
154
|
-
"role": "assistant",
|
|
155
|
-
"content": [
|
|
156
|
-
{"type": "text", "text": "I'll read that file for you."},
|
|
157
|
-
{
|
|
158
|
-
"type": "tool_use",
|
|
159
|
-
"id": "toolu_abc123",
|
|
160
|
-
"name": "read_file",
|
|
161
|
-
"input": {"path": "/tmp/test.txt"},
|
|
162
|
-
},
|
|
163
|
-
],
|
|
164
|
-
},
|
|
165
|
-
{
|
|
166
|
-
"role": "user",
|
|
167
|
-
"content": [
|
|
168
|
-
{
|
|
169
|
-
"type": "tool_result",
|
|
170
|
-
"tool_use_id": "toolu_abc123",
|
|
171
|
-
"content": "File content: Hello World",
|
|
172
|
-
}
|
|
173
|
-
],
|
|
174
|
-
},
|
|
175
|
-
],
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
result = anthropic_to_openai_request(anthropic_request)
|
|
179
|
-
|
|
180
|
-
# Should produce: user, assistant (with tool_calls), tool
|
|
181
|
-
assert len(result["messages"]) == 3
|
|
182
|
-
|
|
183
|
-
# Assistant message
|
|
184
|
-
assistant_msg = result["messages"][1]
|
|
185
|
-
assert assistant_msg["role"] == "assistant"
|
|
186
|
-
assert assistant_msg["content"] == "I'll read that file for you."
|
|
187
|
-
assert len(assistant_msg["tool_calls"]) == 1
|
|
188
|
-
assert assistant_msg["tool_calls"][0]["id"] == "toolu_abc123"
|
|
189
|
-
assert assistant_msg["tool_calls"][0]["type"] == "function"
|
|
190
|
-
assert assistant_msg["tool_calls"][0]["function"]["name"] == "read_file"
|
|
191
|
-
args = json.loads(assistant_msg["tool_calls"][0]["function"]["arguments"])
|
|
192
|
-
assert args == {"path": "/tmp/test.txt"}
|
|
193
|
-
|
|
194
|
-
# Tool result message
|
|
195
|
-
tool_msg = result["messages"][2]
|
|
196
|
-
assert tool_msg["role"] == "tool"
|
|
197
|
-
assert tool_msg["tool_call_id"] == "toolu_abc123"
|
|
198
|
-
assert tool_msg["content"] == "File content: Hello World"
|
|
199
|
-
|
|
200
|
-
print("✅ tool_use/tool_result message conversion: PASSED")
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
def test_tool_result_with_error():
|
|
204
|
-
"""Tool result with is_error flag."""
|
|
205
|
-
anthropic_request = {
|
|
206
|
-
"model": "claude-sonnet-4",
|
|
207
|
-
"max_tokens": 4096,
|
|
208
|
-
"messages": [
|
|
209
|
-
{"role": "user", "content": "Read /nonexistent"},
|
|
210
|
-
{
|
|
211
|
-
"role": "assistant",
|
|
212
|
-
"content": [
|
|
213
|
-
{
|
|
214
|
-
"type": "tool_use",
|
|
215
|
-
"id": "toolu_err123",
|
|
216
|
-
"name": "read_file",
|
|
217
|
-
"input": {"path": "/nonexistent"},
|
|
218
|
-
},
|
|
219
|
-
],
|
|
220
|
-
},
|
|
221
|
-
{
|
|
222
|
-
"role": "user",
|
|
223
|
-
"content": [
|
|
224
|
-
{
|
|
225
|
-
"type": "tool_result",
|
|
226
|
-
"tool_use_id": "toolu_err123",
|
|
227
|
-
"content": "File not found",
|
|
228
|
-
"is_error": True,
|
|
229
|
-
}
|
|
230
|
-
],
|
|
231
|
-
},
|
|
232
|
-
],
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
result = anthropic_to_openai_request(anthropic_request)
|
|
236
|
-
tool_msg = result["messages"][2]
|
|
237
|
-
assert tool_msg["role"] == "tool"
|
|
238
|
-
assert "[ERROR]" in tool_msg["content"]
|
|
239
|
-
|
|
240
|
-
print("✅ tool_result with error: PASSED")
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
# ═══════════════════════════════════════════════════════════════════
|
|
244
|
-
# 5. Non-streaming response with tool_calls
|
|
245
|
-
# ═══════════════════════════════════════════════════════════════════
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
def test_response_with_tool_calls():
|
|
249
|
-
"""OpenAI response with tool_calls → Anthropic tool_use."""
|
|
250
|
-
openai_resp = {
|
|
251
|
-
"id": "chatcmpl-123",
|
|
252
|
-
"choices": [
|
|
253
|
-
{
|
|
254
|
-
"message": {
|
|
255
|
-
"content": "Let me read that file.",
|
|
256
|
-
"tool_calls": [
|
|
257
|
-
{
|
|
258
|
-
"id": "call_abc123",
|
|
259
|
-
"type": "function",
|
|
260
|
-
"function": {
|
|
261
|
-
"name": "read_file",
|
|
262
|
-
"arguments": '{"path": "/tmp/test.txt"}',
|
|
263
|
-
},
|
|
264
|
-
}
|
|
265
|
-
],
|
|
266
|
-
},
|
|
267
|
-
"finish_reason": "tool_calls",
|
|
268
|
-
}
|
|
269
|
-
],
|
|
270
|
-
"usage": {"prompt_tokens": 100, "completion_tokens": 50},
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
result = openai_to_anthropic_response(openai_resp, "claude-sonnet-4")
|
|
274
|
-
|
|
275
|
-
assert result["type"] == "message"
|
|
276
|
-
assert result["role"] == "assistant"
|
|
277
|
-
assert result["stop_reason"] == "tool_use"
|
|
278
|
-
assert len(result["content"]) == 2
|
|
279
|
-
|
|
280
|
-
# Text block
|
|
281
|
-
assert result["content"][0]["type"] == "text"
|
|
282
|
-
assert result["content"][0]["text"] == "Let me read that file."
|
|
283
|
-
|
|
284
|
-
# Tool use block
|
|
285
|
-
assert result["content"][1]["type"] == "tool_use"
|
|
286
|
-
assert result["content"][1]["id"] == "call_abc123"
|
|
287
|
-
assert result["content"][1]["name"] == "read_file"
|
|
288
|
-
assert result["content"][1]["input"] == {"path": "/tmp/test.txt"}
|
|
289
|
-
|
|
290
|
-
print("✅ Non-streaming response with tool_calls: PASSED")
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
def test_response_tool_calls_only():
|
|
294
|
-
"""OpenAI response with tool_calls but no text."""
|
|
295
|
-
openai_resp = {
|
|
296
|
-
"choices": [
|
|
297
|
-
{
|
|
298
|
-
"message": {
|
|
299
|
-
"content": None,
|
|
300
|
-
"tool_calls": [
|
|
301
|
-
{
|
|
302
|
-
"id": "call_xyz",
|
|
303
|
-
"type": "function",
|
|
304
|
-
"function": {
|
|
305
|
-
"name": "write_file",
|
|
306
|
-
"arguments": '{"path": "/tmp/out.txt", "content": "Hello"}',
|
|
307
|
-
},
|
|
308
|
-
}
|
|
309
|
-
],
|
|
310
|
-
},
|
|
311
|
-
"finish_reason": "tool_calls",
|
|
312
|
-
}
|
|
313
|
-
],
|
|
314
|
-
"usage": {},
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
result = openai_to_anthropic_response(openai_resp, "claude-sonnet-4")
|
|
318
|
-
assert result["stop_reason"] == "tool_use"
|
|
319
|
-
assert len(result["content"]) == 1 # Only tool_use, no text
|
|
320
|
-
assert result["content"][0]["type"] == "tool_use"
|
|
321
|
-
|
|
322
|
-
print("✅ Response with tool_calls only (no text): PASSED")
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
# ═══════════════════════════════════════════════════════════════════
|
|
326
|
-
# 6. Streaming response with tool_calls
|
|
327
|
-
# ═══════════════════════════════════════════════════════════════════
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
async def _test_streaming_with_tool_calls():
|
|
331
|
-
"""OpenAI streaming tool_calls → Anthropic tool_use SSE events."""
|
|
332
|
-
|
|
333
|
-
# Simulate OpenAI SSE stream with tool calls
|
|
334
|
-
openai_chunks = [
|
|
335
|
-
# First chunk: text content
|
|
336
|
-
b'data: {"choices":[{"delta":{"role":"assistant","content":""},"index":0}]}\n',
|
|
337
|
-
b'data: {"choices":[{"delta":{"content":"Let me "},"index":0}]}\n',
|
|
338
|
-
b'data: {"choices":[{"delta":{"content":"read that."},"index":0}]}\n',
|
|
339
|
-
# Tool call starts
|
|
340
|
-
b'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_123","type":"function","function":{"name":"read_file","arguments":""}}]},"index":0}]}\n',
|
|
341
|
-
# Tool call arguments stream
|
|
342
|
-
b'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"pa"}}]},"index":0}]}\n',
|
|
343
|
-
b'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"th\\": \\"/tmp"}}]},"index":0}]}\n',
|
|
344
|
-
b'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"/test.txt\\"}"}}]},"index":0}]}\n',
|
|
345
|
-
# Finish
|
|
346
|
-
b'data: {"choices":[{"delta":{},"index":0,"finish_reason":"tool_calls"}]}\n',
|
|
347
|
-
b"data: [DONE]\n",
|
|
348
|
-
]
|
|
349
|
-
|
|
350
|
-
async def mock_stream():
|
|
351
|
-
for chunk in openai_chunks:
|
|
352
|
-
yield chunk
|
|
353
|
-
|
|
354
|
-
events = []
|
|
355
|
-
async for event_bytes in openai_stream_to_anthropic_stream(
|
|
356
|
-
mock_stream(), "claude-sonnet-4"
|
|
357
|
-
):
|
|
358
|
-
text = event_bytes.decode("utf-8").strip()
|
|
359
|
-
for line_pair in text.split("\n\n"):
|
|
360
|
-
lines = line_pair.strip().split("\n")
|
|
361
|
-
if len(lines) >= 2:
|
|
362
|
-
event_type = lines[0].replace("event: ", "")
|
|
363
|
-
data = json.loads(lines[1].replace("data: ", ""))
|
|
364
|
-
events.append((event_type, data))
|
|
365
|
-
|
|
366
|
-
# Verify event sequence
|
|
367
|
-
event_types = [e[0] for e in events]
|
|
368
|
-
|
|
369
|
-
assert "message_start" in event_types
|
|
370
|
-
assert "content_block_start" in event_types
|
|
371
|
-
assert "content_block_delta" in event_types
|
|
372
|
-
assert "content_block_stop" in event_types
|
|
373
|
-
assert "message_delta" in event_types
|
|
374
|
-
assert "message_stop" in event_types
|
|
375
|
-
|
|
376
|
-
# Find tool_use events
|
|
377
|
-
tool_start_events = [
|
|
378
|
-
(t, d) for t, d in events
|
|
379
|
-
if t == "content_block_start" and d.get("content_block", {}).get("type") == "tool_use"
|
|
380
|
-
]
|
|
381
|
-
assert len(tool_start_events) == 1
|
|
382
|
-
assert tool_start_events[0][1]["content_block"]["name"] == "read_file"
|
|
383
|
-
assert tool_start_events[0][1]["content_block"]["id"] == "call_123"
|
|
384
|
-
|
|
385
|
-
# Find input_json_delta events
|
|
386
|
-
json_deltas = [
|
|
387
|
-
(t, d) for t, d in events
|
|
388
|
-
if t == "content_block_delta" and d.get("delta", {}).get("type") == "input_json_delta"
|
|
389
|
-
]
|
|
390
|
-
assert len(json_deltas) >= 1
|
|
391
|
-
|
|
392
|
-
# Reconstruct the arguments
|
|
393
|
-
full_args = "".join(d["delta"]["partial_json"] for _, d in json_deltas)
|
|
394
|
-
parsed = json.loads(full_args)
|
|
395
|
-
assert parsed == {"path": "/tmp/test.txt"}
|
|
396
|
-
|
|
397
|
-
# Check stop_reason
|
|
398
|
-
msg_delta = [d for t, d in events if t == "message_delta"][0]
|
|
399
|
-
assert msg_delta["delta"]["stop_reason"] == "tool_use"
|
|
400
|
-
|
|
401
|
-
print("✅ Streaming with tool_calls: PASSED")
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
def test_streaming_with_tool_calls():
|
|
405
|
-
asyncio.run(_test_streaming_with_tool_calls())
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
# ═══════════════════════════════════════════════════════════════════
|
|
409
|
-
# 7. Multiple tool calls in one response
|
|
410
|
-
# ═══════════════════════════════════════════════════════════════════
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
def test_multiple_tool_calls():
|
|
414
|
-
"""OpenAI response with multiple tool_calls."""
|
|
415
|
-
openai_resp = {
|
|
416
|
-
"choices": [
|
|
417
|
-
{
|
|
418
|
-
"message": {
|
|
419
|
-
"content": None,
|
|
420
|
-
"tool_calls": [
|
|
421
|
-
{
|
|
422
|
-
"id": "call_1",
|
|
423
|
-
"type": "function",
|
|
424
|
-
"function": {
|
|
425
|
-
"name": "read_file",
|
|
426
|
-
"arguments": '{"path": "/a.txt"}',
|
|
427
|
-
},
|
|
428
|
-
},
|
|
429
|
-
{
|
|
430
|
-
"id": "call_2",
|
|
431
|
-
"type": "function",
|
|
432
|
-
"function": {
|
|
433
|
-
"name": "read_file",
|
|
434
|
-
"arguments": '{"path": "/b.txt"}',
|
|
435
|
-
},
|
|
436
|
-
},
|
|
437
|
-
],
|
|
438
|
-
},
|
|
439
|
-
"finish_reason": "tool_calls",
|
|
440
|
-
}
|
|
441
|
-
],
|
|
442
|
-
"usage": {},
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
result = openai_to_anthropic_response(openai_resp, "claude-sonnet-4")
|
|
446
|
-
assert len(result["content"]) == 2
|
|
447
|
-
assert result["content"][0]["type"] == "tool_use"
|
|
448
|
-
assert result["content"][0]["name"] == "read_file"
|
|
449
|
-
assert result["content"][1]["type"] == "tool_use"
|
|
450
|
-
assert result["content"][1]["input"]["path"] == "/b.txt"
|
|
451
|
-
|
|
452
|
-
print("✅ Multiple tool calls: PASSED")
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
# ═══════════════════════════════════════════════════════════════════
|
|
456
|
-
# Run all tests
|
|
457
|
-
# ═══════════════════════════════════════════════════════════════════
|
|
458
|
-
|
|
459
|
-
if __name__ == "__main__":
|
|
460
|
-
test_tools_definition_conversion()
|
|
461
|
-
test_tool_choice_conversion()
|
|
462
|
-
test_full_request_with_tools()
|
|
463
|
-
test_tool_use_message_conversion()
|
|
464
|
-
test_tool_result_with_error()
|
|
465
|
-
test_response_with_tool_calls()
|
|
466
|
-
test_response_tool_calls_only()
|
|
467
|
-
test_streaming_with_tool_calls()
|
|
468
|
-
test_multiple_tool_calls()
|
|
469
|
-
print("\n🎉 All tools translation tests passed!")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|