From ae059ffcb85a284a397a12f36a546665403fb816 Mon Sep 17 00:00:00 2001 From: h88782481 <54714341+h88782481@users.noreply.github.com> Date: Fri, 13 Mar 2026 17:22:28 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=B3=A8=E5=85=A5=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E8=AF=8D=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adapters/responses_cc_adapter.py | 43 ++++++++++++------ routes/admin.py | 4 ++ routes/chat.py | 6 +++ routes/common.py | 75 +++++++++++++++++++++++++++++++- routes/messages.py | 10 ++++- routes/responses.py | 5 +++ settings.py | 6 ++- static/admin.css | 5 ++- static/admin.html | 18 ++++++++ static/admin.js | 8 ++++ 10 files changed, 160 insertions(+), 20 deletions(-) diff --git a/adapters/responses_cc_adapter.py b/adapters/responses_cc_adapter.py index f243e7d..0f35c52 100644 --- a/adapters/responses_cc_adapter.py +++ b/adapters/responses_cc_adapter.py @@ -543,14 +543,17 @@ class ResponsesStreamConverter: def _rewrite_top_level_model(self, payload: JsonDict) -> JsonDict: """在保持上游事件结构不变的前提下回填展示模型名。 - Responses 原生事件经常会在顶层带一个 `model` 字段;这里不重写内部结构,只把这个 - 顶层字段替换为当前映射的 Cursor 模型名,避免界面显示上游原始模型名。 + 原生 Responses 事件中 model 可能在顶层或嵌套在 response 子对象中, + 两处都需要改写,避免界面显示上游原始模型名。 """ if not isinstance(payload, dict): return payload copied = dict(payload) if copied.get('model'): copied['model'] = self.model or copied['model'] + if isinstance(copied.get('response'), dict) and copied['response'].get('model'): + copied['response'] = dict(copied['response']) + copied['response']['model'] = self.model or copied['response']['model'] return copied @@ -597,12 +600,13 @@ class ResponsesToCCStreamConverter: def _handle_output_item_added(self, event_data: JsonDict) -> list[JsonDict]: """处理 Responses 的 output_item.added 事件。 - 这里主要关心 function_call,因为文本和 reasoning 的真正内容会分别在后续 delta - 事件里补全;function_call 则需要先创建一个空参数的 tool_call 槽位。 + 上游事件结构为 {type, item: {type, call_id, name, ...}, output_index}, + 实际的 function_call 信息在 item 子对象中。 """ - if event_data.get('type') != 'function_call': + item = event_data.get('item') or event_data + if item.get('type') != 'function_call': return [] - call_id = event_data.get('call_id') or gen_id('call_') + call_id = item.get('call_id') or gen_id('call_') index = self._tool_slots.setdefault(call_id, self._tool_index) if index == self._tool_index: self._tool_index += 1 @@ -612,7 +616,7 @@ class ResponsesToCCStreamConverter: 'id': call_id, 'type': 'function', 'function': { - 'name': event_data.get('name', ''), + 'name': item.get('name', ''), 'arguments': '', }, }] @@ -633,11 +637,16 @@ class ResponsesToCCStreamConverter: })] def _handle_completed(self, event_data: JsonDict) -> list[JsonDict]: - """处理 response.completed,补出聊天补全流的最终收尾 chunk。""" - self._usage = event_data.get('usage', {}) or {} + """处理 response.completed,补出聊天补全流的最终收尾 chunk。 + + 上游事件结构为 {type, response: {output, usage, ...}}, + 实际的 output/usage 在 response 子对象中。 + """ + resp = event_data.get('response') or event_data + self._usage = resp.get('usage', {}) or {} finish_reason = 'tool_calls' if any( isinstance(item, dict) and item.get('type') == 'function_call' - for item in event_data.get('output', []) + for item in resp.get('output', []) ) else 'stop' chunk = self._make_chunk(delta={}, finish_reason=finish_reason) chunk['usage'] = { @@ -718,7 +727,7 @@ def _append_responses_input_item( item: JsonDict = { 'type': 'message', 'role': role or 'user', - 'content': _content_to_responses_parts(content), + 'content': _content_to_responses_parts(content, role), } input_items.append(item) @@ -1013,13 +1022,19 @@ def _content_to_text(content: Any) -> str: return str(content) if content is not None else '' -def _content_to_responses_parts(content: Any) -> list[JsonDict]: - """将普通消息内容转换为 Responses `input_text` 数组。""" +def _content_to_responses_parts(content: Any, role: str = 'user') -> list[JsonDict]: + """将普通消息内容转换为 Responses 内容块数组。 + + assistant 消息使用 output_text,其他角色使用 input_text。 + """ if isinstance(content, list): text = _extract_text(content) else: text = _content_to_text(content) - return [{'type': 'input_text', 'text': text}] if text else [] + if not text: + return [] + part_type = 'output_text' if role == 'assistant' else 'input_text' + return [{'type': part_type, 'text': text}] def _stringify_output(content: Any) -> str: diff --git a/routes/admin.py b/routes/admin.py index 79125cf..81702aa 100644 --- a/routes/admin.py +++ b/routes/admin.py @@ -135,6 +135,8 @@ def add_mapping(): 'backend': data.get('backend', 'auto'), 'target_url': data.get('target_url', ''), 'api_key': data.get('api_key', ''), + 'custom_instructions': data.get('custom_instructions', ''), + 'instructions_position': data.get('instructions_position', 'prepend'), } return _save_and_respond(s, f'映射已添加: {name}') @@ -157,6 +159,8 @@ def update_mapping(name): 'backend': data.get('backend', 'auto'), 'target_url': data.get('target_url', ''), 'api_key': data.get('api_key', ''), + 'custom_instructions': data.get('custom_instructions', ''), + 'instructions_position': data.get('instructions_position', 'prepend'), } if new_name != name: del mappings[name] diff --git a/routes/chat.py b/routes/chat.py index 7652e4d..4fc03e8 100644 --- a/routes/chat.py +++ b/routes/chat.py @@ -33,6 +33,9 @@ from routes.common import ( build_responses_target, build_route_context, chat_error_chunk, + inject_instructions_anthropic, + inject_instructions_cc, + inject_instructions_responses, log_route_context, log_usage, sse_data_message, @@ -108,6 +111,7 @@ def _handle_openai_backend(ctx: RouteContext, payload: dict[str, Any]): ) payload = normalize_request(payload, ctx.upstream_model) + payload = inject_instructions_cc(payload, ctx.custom_instructions, ctx.instructions_position) _dbg( f'标准化完成:模型={payload.get("model")} ' f'工具数={len(payload.get("tools", []))}' @@ -194,6 +198,7 @@ def _handle_responses_backend(ctx: RouteContext, payload: dict[str, Any]): """ responses_payload = cc_to_responses_request(payload) responses_payload['model'] = ctx.upstream_model + responses_payload = inject_instructions_responses(responses_payload, ctx.custom_instructions, ctx.instructions_position) _dbg( '已转换为 Responses 请求:字段=' + str(list(responses_payload.keys())) + f' 输入项数={len(responses_payload.get("input", []))}' @@ -270,6 +275,7 @@ def _handle_anthropic_backend(ctx: RouteContext, payload: dict[str, Any]): """处理走 Anthropic Messages 后端的聊天补全请求。""" payload['model'] = ctx.upstream_model anthropic_payload = cc_to_messages_request(payload) + anthropic_payload = inject_instructions_anthropic(anthropic_payload, ctx.custom_instructions, ctx.instructions_position) _dbg( '已转换为 Messages 请求:字段=' + str(list(anthropic_payload.keys())) + f' 消息数={len(anthropic_payload.get("messages", []))}' diff --git a/routes/common.py b/routes/common.py index 0b9726c..4855db1 100644 --- a/routes/common.py +++ b/routes/common.py @@ -22,7 +22,8 @@ class RouteContext: """数据面路由使用的标准请求上下文。 路由层会先根据客户端模型名解析出统一上下文,后续处理函数只需要关心 - 上游模型、后端类型、目标地址、鉴权信息和流式标记,而不必重复访问配置层。 + 上游模型、后端类型、目标地址、鉴权信息、流式标记和自定义指令, + 而不必重复访问配置层。 """ client_model: str @@ -31,6 +32,8 @@ class RouteContext: target_url: str api_key: str is_stream: bool + custom_instructions: str + instructions_position: str def build_route_context(client_model: str, is_stream: bool) -> RouteContext: @@ -43,6 +46,8 @@ def build_route_context(client_model: str, is_stream: bool) -> RouteContext: target_url=mapping['target_url'], api_key=mapping['api_key'], is_stream=is_stream, + custom_instructions=mapping.get('custom_instructions', ''), + instructions_position=mapping.get('instructions_position', 'prepend'), ) @@ -120,3 +125,71 @@ def chat_error_chunk(message: str, error_type: str = 'upstream_error') -> str: def responses_error_event(message: str) -> str: """构造 Responses 流式接口使用的错误事件。""" return sse_event_message('error', {'error': message}) + + +# ─── 自定义指令注入 ────────────────────────────── + + +def _merge_text(custom: str, existing: str, position: str) -> str: + """根据 position 决定自定义指令与原有内容的拼接顺序。""" + if not existing: + return custom + if position == 'append': + return existing + '\n\n' + custom + return custom + '\n\n' + existing + + +def inject_instructions_cc(payload: dict[str, Any], instructions: str, position: str = 'prepend') -> dict[str, Any]: + """向 Chat Completions 请求注入自定义指令。 + + position='prepend' 时放在 system 消息开头,'append' 时放在末尾。 + """ + if not instructions: + return payload + + messages = payload.get('messages', []) + if messages and messages[0].get('role') == 'system': + first = messages[0] + original = first.get('content') or '' + first['content'] = _merge_text(instructions, original, position) + else: + messages.insert(0, {'role': 'system', 'content': instructions}) + payload['messages'] = messages + + logger.info('已注入自定义指令到 CC system 消息 (%d 字符, %s)', len(instructions), position) + return payload + + +def inject_instructions_responses(payload: dict[str, Any], instructions: str, position: str = 'prepend') -> dict[str, Any]: + """向 Responses 请求注入自定义指令(写入 instructions 字段)。 + + position='prepend' 时放在 instructions 开头,'append' 时放在末尾。 + """ + if not instructions: + return payload + + existing = payload.get('instructions') or '' + payload['instructions'] = _merge_text(instructions, existing, position) + + logger.info('已注入自定义指令到 Responses instructions (%d 字符, %s)', len(instructions), position) + return payload + + +def inject_instructions_anthropic(payload: dict[str, Any], instructions: str, position: str = 'prepend') -> dict[str, Any]: + """向 Anthropic Messages 请求注入自定义指令(写入 system 字段)。 + + position='prepend' 时放在 system 开头,'append' 时放在末尾。 + """ + if not instructions: + return payload + + existing = payload.get('system') or '' + if isinstance(existing, list): + existing = '\n'.join( + block.get('text', '') for block in existing + if isinstance(block, dict) and block.get('type') == 'text' + ) + payload['system'] = _merge_text(instructions, existing, position) + + logger.info('已注入自定义指令到 Anthropic system (%d 字符, %s)', len(instructions), position) + return payload diff --git a/routes/messages.py b/routes/messages.py index d190036..e552683 100644 --- a/routes/messages.py +++ b/routes/messages.py @@ -13,6 +13,7 @@ from flask import Blueprint, request, jsonify import settings from config import Config +from routes.common import inject_instructions_anthropic from utils.http import build_anthropic_headers, forward_request, sse_response logger = logging.getLogger(__name__) @@ -29,11 +30,16 @@ def messages_passthrough(): logger.info(f'[透传] model={model} 流式={is_stream}') - url_base = settings.get_url() - api_key = settings.get_key() + mapping = settings.resolve_model(model) + url_base = mapping['target_url'] + api_key = mapping['api_key'] + custom_instructions = mapping.get('custom_instructions', '') + instructions_position = mapping.get('instructions_position', 'prepend') headers = build_anthropic_headers(api_key) url = f'{url_base.rstrip("/")}/v1/messages' + payload = inject_instructions_anthropic(payload, custom_instructions, instructions_position) + if not is_stream: resp, err = forward_request(url, headers, payload) if err: diff --git a/routes/responses.py b/routes/responses.py index 25edac8..3548e96 100644 --- a/routes/responses.py +++ b/routes/responses.py @@ -22,6 +22,9 @@ from routes.common import ( build_openai_target, build_responses_target, build_route_context, + inject_instructions_anthropic, + inject_instructions_cc, + inject_instructions_responses, log_route_context, log_usage, responses_error_event, @@ -73,6 +76,7 @@ def _build_cc_payload(payload: dict[str, Any], ctx: RouteContext) -> dict[str, A """ cc_payload = responses_to_cc(payload) cc_payload['model'] = ctx.upstream_model + cc_payload = inject_instructions_cc(cc_payload, ctx.custom_instructions, ctx.instructions_position) _dbg( '已转换为聊天补全中间表示:字段=' + str(list(cc_payload.keys())) + f' 消息数={len(cc_payload.get("messages", []))}' @@ -171,6 +175,7 @@ def _handle_responses_backend(ctx: RouteContext, payload: dict[str, Any]): """ payload = dict(payload) payload['model'] = ctx.upstream_model + payload = inject_instructions_responses(payload, ctx.custom_instructions, ctx.instructions_position) url, headers = build_responses_target(ctx) if ctx.is_stream: diff --git a/settings.py b/settings.py index 63b9b15..9580d21 100644 --- a/settings.py +++ b/settings.py @@ -2,7 +2,7 @@ 使用 data/settings.json 存储可通过管理面板修改的设置: - proxy_target_url / proxy_api_key: 可覆盖环境变量的全局配置 - - model_mappings: Cursor 模型名 → {upstream_model, backend, target_url, api_key} + - model_mappings: Cursor 模型名 → {upstream_model, backend, target_url, api_key, custom_instructions} """ import json @@ -90,6 +90,8 @@ def resolve_model(model_name): 'backend': backend, 'target_url': m.get('target_url') or base_url, 'api_key': m.get('api_key') or base_key, + 'custom_instructions': m.get('custom_instructions') or '', + 'instructions_position': m.get('instructions_position') or 'prepend', } return { @@ -97,6 +99,8 @@ def resolve_model(model_name): 'backend': _auto_detect(model_name), 'target_url': base_url, 'api_key': base_key, + 'custom_instructions': '', + 'instructions_position': 'prepend', } diff --git a/static/admin.css b/static/admin.css index 9b1b197..b31fd9c 100644 --- a/static/admin.css +++ b/static/admin.css @@ -19,8 +19,8 @@ code{background:var(--input);padding:1px 5px;border-radius:4px;font-size:12px;fo .field{margin-bottom:16px} .field label{display:block;font-size:13px;color:var(--muted);margin-bottom:6px;font-weight:500} .input-wrap{position:relative} -.input-wrap input,.input-wrap select{width:100%;background:var(--input);border:1px solid var(--border);border-radius:8px;padding:10px 14px;color:var(--text);font-size:14px;outline:none;transition:border-color .2s} -.input-wrap input:focus,.input-wrap select:focus{border-color:var(--primary)} +.input-wrap input,.input-wrap select,.input-wrap textarea{width:100%;background:var(--input);border:1px solid var(--border);border-radius:8px;padding:10px 14px;color:var(--text);font-size:14px;outline:none;transition:border-color .2s} +.input-wrap input:focus,.input-wrap select:focus,.input-wrap textarea:focus{border-color:var(--primary)} .input-wrap .eye{position:absolute;right:10px;top:50%;transform:translateY(-50%);background:none;border:none;color:var(--muted);cursor:pointer;padding:4px;font-size:16px} select{cursor:pointer;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%238899ab'%3E%3Cpath d='M6 8L1 3h10z'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center} @@ -61,6 +61,7 @@ main{padding:28px 0 60px} .tag-responses{background:rgba(59,130,246,.15);color:#60a5fa} .tag-auto{background:rgba(139,92,246,.15);color:#a78bfa} .tag-override{background:rgba(59,130,246,.1);color:var(--primary)} +.tag-instructions{background:rgba(234,179,8,.15);color:var(--yellow)} .mapping-actions{margin-left:auto;display:flex;gap:6px} .empty{text-align:center;padding:40px;color:var(--muted)} diff --git a/static/admin.html b/static/admin.html index fd245f0..725ea25 100644 --- a/static/admin.html +++ b/static/admin.html @@ -115,6 +115,24 @@ +
+ +
+
+ 每个模型可单独配置。指令会被注入到发往上游的请求中,模型会无条件信任该内容。
+ 适用场景:自定义人格、解除限制、注入 CTF 环境假设等。 +
+
+
+ +
+ +
+
前置:自定义指令放在系统提示词最前面,模型优先看到;后置:放在末尾
+