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.
Files changed (32) hide show
  1. {copilotx-2.3.0 → copilotx-2.3.2}/.gitignore +2 -0
  2. {copilotx-2.3.0 → copilotx-2.3.2}/PKG-INFO +4 -3
  3. {copilotx-2.3.0 → copilotx-2.3.2}/README.md +2 -1
  4. {copilotx-2.3.0 → copilotx-2.3.2}/deploy/nginx-copilotx-http.conf +3 -0
  5. {copilotx-2.3.0 → copilotx-2.3.2}/deploy/nginx-copilotx.conf +2 -2
  6. {copilotx-2.3.0 → copilotx-2.3.2}/pyproject.toml +2 -2
  7. {copilotx-2.3.0 → copilotx-2.3.2}/src/copilotx/__init__.py +1 -1
  8. {copilotx-2.3.0 → copilotx-2.3.2}/src/copilotx/proxy/client.py +49 -4
  9. {copilotx-2.3.0 → copilotx-2.3.2}/src/copilotx/proxy/translator.py +65 -13
  10. {copilotx-2.3.0 → copilotx-2.3.2}/src/copilotx/server/app.py +24 -0
  11. {copilotx-2.3.0 → copilotx-2.3.2}/src/copilotx/server/routes_anthropic.py +20 -9
  12. {copilotx-2.3.0 → copilotx-2.3.2}/src/copilotx/server/routes_openai.py +21 -9
  13. {copilotx-2.3.0 → copilotx-2.3.2}/src/copilotx/server/routes_responses.py +15 -9
  14. copilotx-2.3.0/test_remote.sh +0 -137
  15. copilotx-2.3.0/tests/test_tools_translation.py +0 -469
  16. {copilotx-2.3.0 → copilotx-2.3.2}/LICENSE +0 -0
  17. {copilotx-2.3.0 → copilotx-2.3.2}/deploy/.env.example +0 -0
  18. {copilotx-2.3.0 → copilotx-2.3.2}/deploy/Caddyfile +0 -0
  19. {copilotx-2.3.0 → copilotx-2.3.2}/deploy/copilotx-azureuser.service +0 -0
  20. {copilotx-2.3.0 → copilotx-2.3.2}/deploy/copilotx.service +0 -0
  21. {copilotx-2.3.0 → copilotx-2.3.2}/src/copilotx/__main__.py +0 -0
  22. {copilotx-2.3.0 → copilotx-2.3.2}/src/copilotx/auth/__init__.py +0 -0
  23. {copilotx-2.3.0 → copilotx-2.3.2}/src/copilotx/auth/oauth.py +0 -0
  24. {copilotx-2.3.0 → copilotx-2.3.2}/src/copilotx/auth/storage.py +0 -0
  25. {copilotx-2.3.0 → copilotx-2.3.2}/src/copilotx/auth/token.py +0 -0
  26. {copilotx-2.3.0 → copilotx-2.3.2}/src/copilotx/cli.py +0 -0
  27. {copilotx-2.3.0 → copilotx-2.3.2}/src/copilotx/config.py +0 -0
  28. {copilotx-2.3.0 → copilotx-2.3.2}/src/copilotx/proxy/__init__.py +0 -0
  29. {copilotx-2.3.0 → copilotx-2.3.2}/src/copilotx/proxy/responses_stream.py +0 -0
  30. {copilotx-2.3.0 → copilotx-2.3.2}/src/copilotx/proxy/streaming.py +0 -0
  31. {copilotx-2.3.0 → copilotx-2.3.2}/src/copilotx/server/__init__.py +0 -0
  32. {copilotx-2.3.0 → copilotx-2.3.2}/src/copilotx/server/routes_models.py +0 -0
@@ -24,3 +24,5 @@ Thumbs.db
24
24
 
25
25
  # Project specific
26
26
  *.log
27
+ # Tests (contains API keys and sensitive config)
28
+ tests/
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: copilotx
3
- Version: 2.3.0
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 <polly@polly.wang>
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
- | **v2.2.0** | **Config** | **`copilotx config` command for client setup (Claude Code)** |
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
- | **v2.2.0** | **Config** | **`copilotx config` command for client setup (Claude Code)** |
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 prompts (default 1MB is too small)
42
- client_max_body_size 16M;
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.0"
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 = "polly@polly.wang" }]
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",
@@ -1,3 +1,3 @@
1
1
  """CopilotX — Local GitHub Copilot API proxy."""
2
2
 
3
- __version__ = "2.3.0"
3
+ __version__ = "2.3.2"
@@ -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.raise_for_status()
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.raise_for_status()
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.raise_for_status()
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.raise_for_status()
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
- choice = openai_resp.get("choices", [{}])[0]
386
- message = choice.get("message", {})
387
- content_text = message.get("content", "")
388
- tool_calls = message.get("tool_calls")
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 tool_calls:
399
- for tc in tool_calls:
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
- choice = chunk.get("choices", [{}])[0]
521
- delta = choice.get("delta", {})
522
- chunk_finish = choice.get("finish_reason")
523
- content = delta.get("content")
524
- tool_calls = delta.get("tool_calls")
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
- return JSONResponse(
67
- status_code=502,
68
- content={
69
- "type": "error",
70
- "error": {
71
- "type": "upstream_error",
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
- return JSONResponse(
35
- status_code=502,
36
- content={
37
- "error": {
38
- "message": f"Copilot backend error: {e}",
39
- "type": "upstream_error",
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
- return JSONResponse(
61
- status_code=502,
62
- content={
63
- "error": {
64
- "message": f"Copilot backend error: {e}",
65
- "type": "upstream_error",
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
  # ═══════════════════════════════════════════════════════════════════
@@ -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