From 219f0fd06b8f7fa18da6d3dde92c5026b3e9ceff Mon Sep 17 00:00:00 2001 From: sglwsjxh <267885926+sglwsjxh@users.noreply.github.com> Date: Sat, 16 May 2026 12:17:37 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=BA=86=20anthropic=20?= =?UTF-8?q?=E5=92=8C=20gemini=20=E7=9A=84=20api=20=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adapters/anthropic.py | 300 +++++++++++++++++++++++++++++++ adapters/common.py | 87 +++++++++ adapters/gemini.py | 402 ++++++++++++++++++++++++++++++++++++++++++ adapters/openai.py | 87 +-------- main.py | 17 +- 5 files changed, 810 insertions(+), 83 deletions(-) create mode 100644 adapters/anthropic.py create mode 100644 adapters/common.py create mode 100644 adapters/gemini.py diff --git a/adapters/anthropic.py b/adapters/anthropic.py new file mode 100644 index 0000000..31dd204 --- /dev/null +++ b/adapters/anthropic.py @@ -0,0 +1,300 @@ +""" +Anthropic Messages API 协议兼容适配器。 + +接收 Anthropic Messages API 格式请求 (/v1/messages), +转换为 OpenAI chat/completions 格式发送到 Copilot API, +再将 Copilot 响应转换回 Anthropic 格式。 +""" + +import json +from typing import Any +import uuid + +import requests +from flask import Response, request + +from adapters.base import BaseAdapter +from adapters.common import clean_body, get_fallback_model, is_model_not_supported, set_fallback_model, try_fallback +from auth import get_copilot_token +from config import COPILOT_API_BASE +from proxy import build_headers, forward_request + +STREAM_END_MARKER = 'data: [DONE]\n\n' + +FINISH_REASON_MAP: dict[str | None, str] = { + 'stop': 'end_turn', + 'length': 'max_tokens', + 'content_filter': 'content_filter', +} + +ROLE_MAP: dict[str, str] = { + 'user': 'user', + 'assistant': 'assistant', +} + + +def _convert_anthropic_messages(anthropic_msg: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Anthropic messages → OpenAI messages (role/content pairs).""" + result = [] + for msg in anthropic_msg: + role = msg.get('role', 'user') + content = msg.get('content', '') + if isinstance(content, list): + texts = [] + for block in content: + if isinstance(block, dict) and block.get('type') == 'text': + texts.append(block.get('text', '')) + content = '\n'.join(texts) + result.append({'role': ROLE_MAP.get(role, role), 'content': content}) + return result + + +def anthropic_to_openai(payload: dict[str, Any]) -> dict[str, Any]: + """将 Anthropic Messages API 请求转换为 OpenAI chat/completions 格式。""" + messages = [] + + system = payload.get('system') + if system: + messages.append({'role': 'system', 'content': system}) + + anthropic_msgs = payload.get('messages', []) + messages.extend(_convert_anthropic_messages(anthropic_msgs)) + + oai_payload: dict[str, Any] = { + 'model': payload.get('model', ''), + 'messages': messages, + 'max_tokens': payload.get('max_tokens', 4096), + 'stream': payload.get('stream', False), + } + + if 'temperature' in payload: + oai_payload['temperature'] = payload['temperature'] + if 'top_p' in payload: + oai_payload['top_p'] = payload['top_p'] + if 'stop_sequences' in payload: + oai_payload['stop'] = payload['stop_sequences'] + if 'metadata' in payload: + oai_payload['metadata'] = payload['metadata'] + + return oai_payload + + +def _map_finish_reason(oai_reason: str | None) -> str: + return FINISH_REASON_MAP.get(oai_reason, 'end_turn') + + +def openai_to_anthropic_response(oai_data: dict[str, Any]) -> dict[str, Any]: + """将 OpenAI chat/completions 响应转换为 Anthropic Messages API 格式。""" + choices = oai_data.get('choices', [{}]) + choice = choices[0] if choices else {} + message = choice.get('message', {}) + content_text = message.get('content', '') or '' + finish = _map_finish_reason(choice.get('finish_reason')) + + oai_usage = oai_data.get('usage', {}) or {} + anthropic_usage = { + 'input_tokens': oai_usage.get('prompt_tokens', 0), + 'output_tokens': oai_usage.get('completion_tokens', 0), + } + + return { + 'id': f"msg_{oai_data.get('id', str(uuid.uuid4().hex[:24]))}", + 'type': 'message', + 'role': 'assistant', + 'content': [{'type': 'text', 'text': content_text}], + 'model': oai_data.get('model', ''), + 'stop_reason': finish, + 'stop_sequence': None, + 'usage': anthropic_usage, + } + + +def _build_anthropic_usage(prompt_tokens: int = 0, completion_tokens: int = 0) -> dict[str, Any]: + return {'input_tokens': prompt_tokens, 'output_tokens': completion_tokens} + + +def openai_sse_to_anthropic_sse() -> Response: + """将 OpenAI SSE 流实时转换为 Anthropic SSE 事件流。""" + url = request.url.replace('/v1/messages', '/v1/chat/completions') + copilot_url = f"{COPILOT_API_BASE}/chat/completions" + + headers = build_headers(request.headers.get('Content-Type', 'application/json')) + raw_body = clean_body(request.get_data()) + body_str = raw_body.decode('utf-8') if raw_body else '{}' + oai_body = anthropic_to_openai(json.loads(body_str)) + oai_body['stream'] = True + new_body = json.dumps(oai_body).encode() + + upstream = forward_request('POST', copilot_url, headers, new_body, stream=True) + + if upstream.status_code == 400 and is_model_not_supported(upstream): + fb = try_fallback('POST', copilot_url, headers, new_body) + if fb is not None: + upstream = fb + + if upstream.status_code != 200: + error_text = upstream.text[:500] + print(f"[Anthropic] API 返回 {upstream.status_code}: {error_text}") + return _anthropic_error_response(upstream.status_code, error_text) + + def generate(): + message_id = f"msg_{uuid.uuid4().hex[:24]}" + model_name = oai_body.get('model', '') + has_started = False + prompt_tokens = 0 + completion_tokens = 0 + stop_reason = None + + for chunk_bytes in upstream.iter_content(chunk_size=1024): + if not chunk_bytes: + continue + + for line in chunk_bytes.decode('utf-8', errors='replace').splitlines(): + line = line.strip() + if not line or line == STREAM_END_MARKER.strip(): + continue + if not line.startswith('data: '): + continue + + data_str = line[6:] + if data_str == '[DONE]': + break + + try: + chunk = json.loads(data_str) + except json.JSONDecodeError: + continue + + choices = chunk.get('choices', [{}]) + delta = choices[0].get('delta', {}) if choices else {} + finish = choices[0].get('finish_reason') if choices else None + + if not has_started: + has_started = True + usage = chunk.get('usage', {}) or {} + prompt_tokens = usage.get('prompt_tokens', 0) + completion_tokens = usage.get('completion_tokens', 0) + + msg_start = { + 'type': 'message_start', + 'message': { + 'id': message_id, + 'type': 'message', + 'role': 'assistant', + 'content': [], + 'model': model_name, + 'stop_reason': None, + 'stop_sequence': None, + 'usage': _build_anthropic_usage(prompt_tokens, 0), + }, + } + yield f"event: message_start\ndata: {json.dumps(msg_start)}\n\n" + + content_block = {'type': 'text', 'text': ''} + cb_start = { + 'type': 'content_block_start', + 'index': 0, + 'content_block': content_block, + } + yield f"event: content_block_start\ndata: {json.dumps(cb_start)}\n\n" + + text = delta.get('content', '') + if text: + cb_delta = { + 'type': 'content_block_delta', + 'index': 0, + 'delta': {'type': 'text_delta', 'text': text}, + } + yield f"event: content_block_delta\ndata: {json.dumps(cb_delta)}\n\n" + + if finish: + stop_reason = _map_finish_reason(finish) + yield f"event: content_block_stop\ndata: {json.dumps({'type': 'content_block_stop', 'index': 0})}\n\n" + + msg_delta = { + 'type': 'message_delta', + 'delta': {'stop_reason': stop_reason, 'stop_sequence': None}, + 'usage': _build_anthropic_usage(0, completion_tokens), + } + yield f"event: message_delta\ndata: {json.dumps(msg_delta)}\n\n" + + yield f"event: message_stop\ndata: {json.dumps({'type': 'message_stop'})}\n\n" + + if not has_started: + yield _anthropic_sse_error(503, '上游无响应') + + return Response( + generate(), + mimetype='text/event-stream', + headers={ + 'Cache-Control': 'no-cache', + 'X-Accel-Buffering': 'no', + }, + ) + + +def _anthropic_error_response(status_code: int, message: str) -> Response: + body = { + 'type': 'error', + 'error': { + 'type': 'api_error', + 'message': message, + }, + } + return Response(json.dumps(body), status=status_code, mimetype='application/json') + + +def _anthropic_sse_error(status_code: int, message: str) -> str: + err = { + 'type': 'error', + 'error': {'type': 'api_error', 'message': message}, + } + return f"event: error\ndata: {json.dumps(err)}\n\n" + + +class AnthropicAdapter(BaseAdapter): + + def handle_request(self, path: str) -> Response | tuple[dict[str, Any], int]: + if get_copilot_token() is None: + return {'error': 'Copilot token 未就绪,请检查授权状态'}, 503 + + try: + payload = request.get_json(force=True) + except Exception: + return {'error': '无效的 JSON 请求体'}, 400 + + if not payload or 'messages' not in payload: + return {'error': '缺少必填字段: messages'}, 400 + + stream = payload.get('stream', False) + + if stream: + return openai_sse_to_anthropic_sse() + + oai_payload = anthropic_to_openai(payload) + copilot_url = f'{COPILOT_API_BASE}/chat/completions' + headers = build_headers('application/json') + body = json.dumps(oai_payload).encode() + + try: + resp = forward_request('POST', copilot_url, headers, body) + + if resp.status_code == 400 and is_model_not_supported(resp): + fb = try_fallback('POST', copilot_url, headers, body) + if fb is not None: + resp = fb + + if resp.status_code != 200: + error_text = resp.text[:500] + print(f'[Anthropic] API 返回 {resp.status_code}: {error_text}') + sc: int = resp.status_code + return _anthropic_error_response(sc, error_text) + + oai_data = resp.json() + anthropic_resp = openai_to_anthropic_response(oai_data) + return Response(json.dumps(anthropic_resp), mimetype='application/json') + except requests.exceptions.Timeout: + return {'error': '请求超时'}, 504 + except Exception as e: + print(f'[✗] Anthropic 代理错误: {e}') + return {'error': str(e)}, 502 diff --git a/adapters/common.py b/adapters/common.py new file mode 100644 index 0000000..58d4442 --- /dev/null +++ b/adapters/common.py @@ -0,0 +1,87 @@ +import json + +import requests +import fallback as fallback_module +from config import PROXY_PORT +from proxy import forward_request + +_fallback_model: str | None = None + + +def get_fallback_model() -> str | None: + return _fallback_model + + +def set_fallback_model(model: str | None) -> None: + global _fallback_model + _fallback_model = model + + +def is_model_not_supported(resp: requests.Response) -> bool: + try: + err_json = resp.json() + err = err_json.get('error', {}) if isinstance(err_json, dict) else {} + code = err.get('code') + msg = err.get('message', '') + except Exception: + code = None + msg = resp.text or '' + return code == 'model_not_supported' or 'not supported' in (msg or '').lower() + + +def try_fallback( + method: str, + url: str, + headers: dict[str, str], + body: bytes | None, +) -> requests.Response | None: + global _fallback_model + + try: + requested_model: str | None = None + new_body = body + if body: + text_body = body.decode() if isinstance(body, (bytes, bytearray)) else body + parsed = json.loads(text_body) + if isinstance(parsed, dict) and parsed.get('model'): + requested_model = parsed.get('model') + if not _fallback_model: + set_fallback_model( + fallback_module.choose_fallback_model( + models_url=f'http://localhost:{PROXY_PORT}/v1/models' + ) + ) + parsed['model'] = _fallback_model + new_body = json.dumps(parsed).encode() + + if not _fallback_model: + print(f"[!] 模型不可用: {requested_model or 'unknown'},且未找到可用 fallback") + return None + + print(f"[!] 模型不可用: {requested_model or 'unknown'},回退到: {_fallback_model}") + + resp2 = forward_request( + method=method, + url=url, + headers=headers, + data=new_body, + ) + if resp2.status_code != 200: + error_text = resp2.text[:500] + print(f"[!] 回退尝试仍返回 {resp2.status_code}: {error_text}") + return resp2 + except Exception as e: + print(f"[!] 回退重试失败: {e}") + return None + + +def clean_body(raw_body: bytes | None) -> bytes | None: + if not raw_body: + return raw_body + try: + data = json.loads(raw_body) + for key in ['api_key', 'api_base']: + data.pop(key, None) + return json.dumps(data).encode() + except Exception: + return raw_body diff --git a/adapters/gemini.py b/adapters/gemini.py new file mode 100644 index 0000000..b2056e2 --- /dev/null +++ b/adapters/gemini.py @@ -0,0 +1,402 @@ +""" +Gemini API 协议兼容适配器。 + +接收 Gemini API 格式请求 (/v1beta/models/{model}:generateContent 等), +转换为 OpenAI chat/completions 格式发送到 Copilot API, +再将 Copilot 响应转换回 Gemini 格式。 +""" + +import json +import re +from typing import Any +import uuid + +import requests +from flask import Response, request + +from adapters.base import BaseAdapter +from adapters.common import get_fallback_model, is_model_not_supported, set_fallback_model, try_fallback +from auth import get_copilot_token +from config import COPILOT_API_BASE +from proxy import build_headers, forward_request + +STREAM_END_MARKER = 'data: [DONE]' + +FINISH_REASON_MAP: dict[str | None, str] = { + 'stop': 'STOP', + 'length': 'MAX_TOKENS', + 'content_filter': 'SAFETY', +} + +GEMINI_ROLE_MAP: dict[str, str] = { + 'user': 'user', + 'model': 'assistant', +} + + +def parse_gemini_path(gemini_path: str) -> tuple[str, str] | None: + """ + 从 Gemini 路径中提取 model 名称和 action。 + 例如: 'claude-sonnet-4:generateContent' → ('claude-sonnet-4', 'generateContent') + 'publishers/google/models/gemini-pro:streamGenerateContent' → ('gemini-pro', 'streamGenerateContent') + """ + last_colon = gemini_path.rfind(':') + if last_colon == -1: + return None + + action = gemini_path[last_colon + 1:] + if action not in ('generateContent', 'streamGenerateContent'): + return None + + model_part = gemini_path[:last_colon] + model_match = re.search(r'(?:^|/)models/([^:]+)$', model_part) + if model_match: + model = model_match.group(1) + else: + model = model_part + + return (model, action) + + +def _gemini_contents_to_openai_messages(contents: list[dict[str, Any]]) -> list[dict[str, Any]]: + """将 Gemini contents 数组转换为 OpenAI messages。""" + messages = [] + for item in contents: + role = item.get('role', 'user') + parts = item.get('parts', []) + texts = [] + for part in parts: + if isinstance(part, dict) and 'text' in part: + texts.append(part['text']) + content = '\n'.join(texts) if texts else '' + messages.append({'role': GEMINI_ROLE_MAP.get(role, role), 'content': content}) + return messages + + +def gemini_to_openai(model: str, payload: dict[str, Any], stream: bool = False) -> dict[str, Any]: + """将 Gemini 请求转换为 OpenAI chat/completions 格式。""" + messages = [] + + system_inst = payload.get('systemInstruction', {}) or {} + if isinstance(system_inst, dict): + system_parts = system_inst.get('parts', []) + system_texts = [p.get('text', '') for p in system_parts if isinstance(p, dict)] + if system_texts: + messages.append({'role': 'system', 'content': '\n'.join(system_texts)}) + + contents = payload.get('contents', []) + messages.extend(_gemini_contents_to_openai_messages(contents)) + + gc = payload.get('generationConfig', {}) or {} + + oai_payload: dict[str, Any] = { + 'model': model, + 'messages': messages, + 'stream': stream, + } + + if 'maxOutputTokens' in gc: + oai_payload['max_tokens'] = gc['maxOutputTokens'] + if 'temperature' in gc: + oai_payload['temperature'] = gc['temperature'] + if 'topP' in gc: + oai_payload['top_p'] = gc['topP'] + if 'stopSequences' in gc: + oai_payload['stop'] = gc['stopSequences'] + + return oai_payload + + +def _map_gemini_finish(reason: str | None) -> str: + return FINISH_REASON_MAP.get(reason, 'STOP') + + +def openai_to_gemini_response(oai_data: dict[str, Any]) -> dict[str, Any]: + """将 OpenAI chat/completions 响应转换为 Gemini generateContent 格式。""" + choices = oai_data.get('choices', [{}]) + choice = choices[0] if choices else {} + message = choice.get('message', {}) + content_text = message.get('content', '') or '' + finish = _map_gemini_finish(choice.get('finish_reason')) + + oai_usage = oai_data.get('usage', {}) or {} + + return { + 'candidates': [ + { + 'content': { + 'role': 'model', + 'parts': [{'text': content_text}], + }, + 'finishReason': finish, + 'safetyRatings': [], + 'index': 0, + } + ], + 'usageMetadata': { + 'promptTokenCount': oai_usage.get('prompt_tokens', 0), + 'candidatesTokenCount': oai_usage.get('completion_tokens', 0), + 'totalTokenCount': oai_usage.get('total_tokens', 0), + }, + 'modelVersion': oai_data.get('model', ''), + } + + +def openai_sse_to_gemini_sse() -> Response: + """将 OpenAI SSE 流实时转换为 Gemini SSE 流。""" + copilot_url = f'{COPILOT_API_BASE}/chat/completions' + headers = build_headers('application/json') + raw_body = request.get_data() + body_str = raw_body.decode('utf-8') if raw_body else '{}' + + try: + payload = json.loads(body_str) + except json.JSONDecodeError: + return Response(json.dumps({'error': {'message': '无效的 JSON'}}), status=400, mimetype='application/json') + + gemini_path_match = parse_gemini_path(request.path[len('/v1beta/models/'):]) + if not gemini_path_match: + return Response(json.dumps({'error': {'message': '无效的请求路径'}}), status=400, mimetype='application/json') + model, _ = gemini_path_match + + oai_payload = gemini_to_openai(model, payload) + oai_payload['stream'] = True + new_body = json.dumps(oai_payload).encode() + + upstream = forward_request('POST', copilot_url, headers, new_body, stream=True) + + if upstream.status_code == 400 and is_model_not_supported(upstream): + fb = try_fallback('POST', copilot_url, headers, new_body) + if fb is not None: + upstream = fb + + has_safety = 'safetySettings' in payload + + sc: int = upstream.status_code + if sc != 200: + error_text = upstream.text[:500] + print(f'[Gemini] API 返回 {sc}: {error_text}') + return _gemini_error_response(sc, error_text) + + def generate(): + for chunk_bytes in upstream.iter_content(chunk_size=1024): + if not chunk_bytes: + continue + + for line in chunk_bytes.decode('utf-8', errors='replace').splitlines(): + line = line.strip() + if not line: + continue + if line == STREAM_END_MARKER: + break + if not line.startswith('data: '): + continue + + data_str = line[6:] + if data_str == '[DONE]': + break + + try: + chunk = json.loads(data_str) + except json.JSONDecodeError: + continue + + choices = chunk.get('choices', [{}]) + delta = choices[0].get('delta', {}) if choices else {} + finish = choices[0].get('finish_reason') if choices else None + text = delta.get('content', '') + + gemini_chunk: dict = { + 'candidates': [ + { + 'index': 0, + 'content': { + 'role': 'model', + 'parts': [{'text': text}] if text else [], + }, + } + ] + } + + if finish: + gemini_chunk['candidates'][0]['finishReason'] = _map_gemini_finish(finish) + + yield f'data: {json.dumps(gemini_chunk)}\n\n' + + resp = Response( + generate(), + mimetype='text/event-stream', + headers={ + 'Cache-Control': 'no-cache', + 'X-Accel-Buffering': 'no', + }, + ) + + if has_safety: + resp.headers['X-Gemini-Warning'] = 'safety_settings_ignored' + + return resp + + +def _gemini_missing_contents() -> tuple[dict[str, Any], int]: + return {'error': {'message': 'Missing required field: contents', 'code': 400}}, 400 + + +def _gemini_invalid_path() -> tuple[dict[str, Any], int]: + return {'error': {'message': 'Invalid request path', 'code': 400}}, 400 + + +def _gemini_error_response(status_code: int, message: str) -> Response: + body = { + 'error': { + 'code': status_code, + 'message': message, + 'status': 'UNAVAILABLE' if status_code >= 500 else 'INVALID_ARGUMENT', + } + } + return Response(json.dumps(body), status=status_code, mimetype='application/json') + + +class GeminiAdapter(BaseAdapter): + + def handle_request(self, path: str) -> Response | tuple[dict[str, Any], int]: + if get_copilot_token() is None: + return {'error': {'message': 'Copilot token 未就绪,请检查授权状态', 'code': 503}}, 503 + + try: + payload = request.get_json(force=True) + except Exception: + return {'error': {'message': 'Invalid JSON body', 'code': 400}}, 400 + + if not payload or 'contents' not in payload: + return _gemini_missing_contents() + + full_path = f'v1beta/models/{path}' + gemini_path_match = parse_gemini_path(path) + if not gemini_path_match: + return _gemini_invalid_path() + model, action = gemini_path_match + + stream = action == 'streamGenerateContent' + + has_safety = 'safetySettings' in payload + + if stream: + return self._handle_stream(payload, model, has_safety) + + return self._handle_non_stream(payload, model, has_safety) + + def _handle_non_stream( + self, payload: dict[str, Any], model: str, has_safety: bool + ) -> Response | tuple[dict[str, Any], int]: + oai_payload = gemini_to_openai(model, payload) + copilot_url = f'{COPILOT_API_BASE}/chat/completions' + headers = build_headers('application/json') + body = json.dumps(oai_payload).encode() + + try: + resp = forward_request('POST', copilot_url, headers, body) + + if resp.status_code == 400 and is_model_not_supported(resp): + fb = try_fallback('POST', copilot_url, headers, body) + if fb is not None: + resp = fb + + if resp.status_code != 200: + error_text = resp.text[:500] + print(f'[Gemini] API 返回 {resp.status_code}: {error_text}') + sc: int = resp.status_code + return _gemini_error_response(sc, error_text) + + oai_data = resp.json() + gemini_resp = openai_to_gemini_response(oai_data) + flask_resp = Response(json.dumps(gemini_resp), mimetype='application/json') + + if has_safety: + flask_resp.headers['X-Gemini-Warning'] = 'safety_settings_ignored' + + return flask_resp + except requests.exceptions.Timeout: + return {'error': {'message': '请求超时', 'code': 504}}, 504 + except Exception as e: + print(f'[✗] Gemini 代理错误: {e}') + return {'error': {'message': str(e), 'code': 502}}, 502 + + def _handle_stream(self, payload: dict[str, Any], model: str, has_safety: bool) -> Response: + oai_payload = gemini_to_openai(model, payload) + oai_payload['stream'] = True + copilot_url = f'{COPILOT_API_BASE}/chat/completions' + headers = build_headers('application/json') + body = json.dumps(oai_payload).encode() + + upstream = forward_request('POST', copilot_url, headers, body, stream=True) + + if upstream.status_code == 400 and is_model_not_supported(upstream): + fb = try_fallback('POST', copilot_url, headers, body) + if fb is not None: + upstream = fb + + sc: int = upstream.status_code + if sc != 200: + error_text = upstream.text[:500] + print(f'[Gemini] API 返回 {sc}: {error_text}') + return _gemini_error_response(sc, error_text) + + def generate(): + for chunk_bytes in upstream.iter_content(chunk_size=1024): + if not chunk_bytes: + continue + + for line in chunk_bytes.decode('utf-8', errors='replace').splitlines(): + line = line.strip() + if not line: + continue + if line == STREAM_END_MARKER: + break + if not line.startswith('data: '): + continue + + data_str = line[6:] + if data_str == '[DONE]': + break + + try: + chunk = json.loads(data_str) + except json.JSONDecodeError: + continue + + choices = chunk.get('choices', [{}]) + delta = choices[0].get('delta', {}) if choices else {} + finish = choices[0].get('finish_reason') if choices else None + text = delta.get('content', '') + + gemini_chunk: dict[str, Any] = { + 'candidates': [ + { + 'index': 0, + 'content': { + 'role': 'model', + 'parts': [{'text': text}] if text else [], + }, + } + ] + } + + if finish: + gemini_chunk['candidates'][0]['finishReason'] = _map_gemini_finish(finish) + + yield f'data: {json.dumps(gemini_chunk)}\n\n' + + resp = Response( + generate(), + mimetype='text/event-stream', + headers={ + 'Cache-Control': 'no-cache', + 'X-Accel-Buffering': 'no', + }, + ) + + if has_safety: + resp.headers['X-Gemini-Warning'] = 'safety_settings_ignored' + + return resp diff --git a/adapters/openai.py b/adapters/openai.py index d973a74..665a039 100644 --- a/adapters/openai.py +++ b/adapters/openai.py @@ -1,49 +1,12 @@ -import json - import requests from flask import request -import fallback from adapters.base import BaseAdapter +from adapters.common import clean_body, is_model_not_supported, try_fallback from auth import get_copilot_token -from config import PROXY_PORT, COPILOT_API_BASE +from config import COPILOT_API_BASE from proxy import build_headers, build_response, forward_request -_fallback_model = None - - -def get_fallback_model(): - return _fallback_model - - -def set_fallback_model(model): - global _fallback_model - _fallback_model = model - - -def _clean_body(raw_body): - if not raw_body: - return raw_body - try: - data = json.loads(raw_body) - for key in ['api_key', 'api_base']: - data.pop(key, None) - return json.dumps(data).encode() - except Exception: - return raw_body - - -def _is_model_not_supported(resp): - try: - err_json = resp.json() - err = err_json.get('error', {}) if isinstance(err_json, dict) else {} - code = err.get('code') - msg = err.get('message', '') - except Exception: - code = None - msg = resp.text or '' - return code == 'model_not_supported' or 'not supported' in (msg or '').lower() - class OpenAIAdapter(BaseAdapter): @@ -55,7 +18,7 @@ def handle_request(self, path): url = f"{COPILOT_API_BASE}/{normalized_path}" headers = build_headers(request.headers.get('Content-Type', 'application/json')) - body = _clean_body(request.get_data()) + body = clean_body(request.get_data()) try: resp = forward_request( @@ -69,8 +32,8 @@ def handle_request(self, path): error_text = resp.text[:500] print(f"[!] API 返回 {resp.status_code}: {error_text}") - if resp.status_code == 400 and _is_model_not_supported(resp): - fallback_resp = self._try_fallback(url, headers, body) + if resp.status_code == 400 and is_model_not_supported(resp): + fallback_resp = try_fallback(request.method, url, headers, body) if fallback_resp is not None: resp = fallback_resp @@ -80,43 +43,3 @@ def handle_request(self, path): except Exception as e: print(f"[✗] 代理错误: {e}") return {"error": str(e)}, 502 - - def _try_fallback(self, url, headers, body): - global _fallback_model - - try: - requested_model = None - new_body = body - if body: - text_body = body.decode() if isinstance(body, (bytes, bytearray)) else body - parsed = json.loads(text_body) - if isinstance(parsed, dict) and parsed.get('model'): - requested_model = parsed.get('model') - if not _fallback_model: - set_fallback_model( - fallback.choose_fallback_model( - models_url=f'http://localhost:{PROXY_PORT}/v1/models' - ) - ) - parsed['model'] = _fallback_model - new_body = json.dumps(parsed).encode() - - if not _fallback_model: - print(f"[!] 模型不可用: {requested_model or 'unknown'},且未找到可用 fallback") - return None - - print(f"[!] 模型不可用: {requested_model or 'unknown'},回退到: {_fallback_model}") - - resp2 = forward_request( - method=request.method, - url=url, - headers=headers, - data=new_body, - ) - if resp2.status_code != 200: - error_text = resp2.text[:500] - print(f"[!] 回退尝试仍返回 {resp2.status_code}: {error_text}") - return resp2 - except Exception as e: - print(f"[!] 回退重试失败: {e}") - return None diff --git a/main.py b/main.py index fc18b97..de110c1 100644 --- a/main.py +++ b/main.py @@ -9,11 +9,26 @@ import auth import fallback import models -from adapters.openai import OpenAIAdapter, get_fallback_model, set_fallback_model +from adapters.anthropic import AnthropicAdapter +from adapters.common import get_fallback_model, set_fallback_model +from adapters.gemini import GeminiAdapter +from adapters.openai import OpenAIAdapter from config import PROXY_PORT, TOKEN_FILE app = Flask(__name__) openai_adapter = OpenAIAdapter() +anthropic_adapter = AnthropicAdapter() +gemini_adapter = GeminiAdapter() + + +@app.route('/v1/messages', methods=['POST']) +def anthropic_messages(): + return anthropic_adapter.handle_request('messages') + + +@app.route('/v1beta/models/', methods=['POST']) +def gemini_models(gemini_path): + return gemini_adapter.handle_request(gemini_path) @app.route('/', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS']) From 8f8a7e78ef0c60b1d32f0dc980ba4f76687d6e8c Mon Sep 17 00:00:00 2001 From: sglwsjxh <267885926+sglwsjxh@users.noreply.github.com> Date: Sat, 16 May 2026 12:40:09 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E4=BA=86=E6=9C=89?= =?UTF-8?q?=E5=85=B3=E7=B4=A7=E8=A6=81=E7=9A=84=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 +- readme.md | 4 ++-- readme_cn.md | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 8a93309..330269e 100644 --- a/.gitignore +++ b/.gitignore @@ -13,5 +13,5 @@ pyrightconfig.json *.json # Test Scripts -test.* +test* getmodels.* \ No newline at end of file diff --git a/readme.md b/readme.md index 3f57bc6..b87feec 100644 --- a/readme.md +++ b/readme.md @@ -19,7 +19,7 @@ Convert GitHub Copilot into an OpenAI-compatible API with one click. Works with - Copilot Pro - Copilot Education -2. **Python 3.7+** +2. **Python 3.10+** ## ⬇️ Download & Install @@ -142,7 +142,7 @@ Open the Continue sidebar in VS Code, select a model, and start chatting. ### Change Port -Edit the top of `main.py`: +Edit the top of `config.py`: ```python PROXY_PORT = 15432 # Change to your desired port diff --git a/readme_cn.md b/readme_cn.md index 4bbc695..dc21029 100644 --- a/readme_cn.md +++ b/readme_cn.md @@ -19,7 +19,7 @@ - Copilot Pro - Copilot 教育版 -2. **Python 3.7+** +2. **Python 3.10+** ## ⬇️ 下载安装 @@ -142,7 +142,7 @@ models: ### 修改端口 -编辑 `main.py` 顶部: +编辑 `config.py` 顶部: ```python PROXY_PORT = 15432 # 改成你想要的端口