feat: intercept identity questions and return consistent Claude identity
Some checks failed
Build Docker Image / build (push) Has been cancelled
Some checks failed
Build Docker Image / build (push) Has been cancelled
Kiro's upstream system prompt overrides all user-provided system prompts and returns "I can't discuss that" for identity questions. This pre-flight interceptor detects identity questions (Chinese and English patterns) in the last user message and returns a Claude-style response directly, bypassing Kiro entirely. Response language matches the question language; model name reflects the requested model (Claude Opus 4.7, Claude Sonnet 4.5, etc.). Applied to both /v1/messages (Claude) and /v1/chat/completions (OpenAI). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -717,6 +717,17 @@ func (h *Handler) handleClaudeMessagesInternal(w http.ResponseWriter, r *http.Re
|
||||
// 转换请求
|
||||
kiroPayload := ClaudeToKiro(&req, thinking)
|
||||
|
||||
// 身份问题预检:直接返回 Claude 身份,跳过 Kiro(Kiro 上游系统提示会无视任何身份覆盖指令)
|
||||
lastUserText := getLastUserTextClaude(req.Messages)
|
||||
if isIdentityQuestion(lastUserText) {
|
||||
if req.Stream {
|
||||
h.sendClaudeIdentityStream(w, req.Model, lastUserText, estimatedInputTokens)
|
||||
} else {
|
||||
h.sendClaudeIdentityNonStream(w, req.Model, lastUserText, estimatedInputTokens)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Stream or non-stream
|
||||
if req.Stream {
|
||||
h.handleClaudeStream(w, account, kiroPayload, req.Model, thinking, thinkingResponseOpts, estimatedInputTokens, cacheUsage, cacheProfile)
|
||||
@@ -1353,6 +1364,13 @@ func (h *Handler) handleOpenAIChat(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
kiroPayload := OpenAIToKiro(&req, thinking)
|
||||
|
||||
// 身份问题预检
|
||||
lastUserText := getLastUserTextOpenAI(req.Messages)
|
||||
if isIdentityQuestion(lastUserText) {
|
||||
h.sendOpenAIIdentityNonStream(w, req.Model, lastUserText, estimatedInputTokens)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Stream {
|
||||
h.handleOpenAIStream(w, account, kiroPayload, req.Model, thinking, estimatedInputTokens)
|
||||
} else {
|
||||
|
||||
225
proxy/identity.go
Normal file
225
proxy/identity.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// identityPatterns covers common ways users ask about the AI's identity.
|
||||
var identityPatterns = []*regexp.Regexp{
|
||||
// English
|
||||
regexp.MustCompile(`(?i)\bwho are you\b`),
|
||||
regexp.MustCompile(`(?i)\bwhat are you\b`),
|
||||
regexp.MustCompile(`(?i)\bwhat model\b`),
|
||||
regexp.MustCompile(`(?i)\bwhich model\b`),
|
||||
regexp.MustCompile(`(?i)\byour (name|identity|model|version)\b`),
|
||||
regexp.MustCompile(`(?i)\btell me about yourself\b`),
|
||||
regexp.MustCompile(`(?i)\bidentify yourself\b`),
|
||||
regexp.MustCompile(`(?i)\bwhat (llm|language model) are you\b`),
|
||||
regexp.MustCompile(`(?i)\bwhat (ai|assistant) are you\b`),
|
||||
// Chinese
|
||||
regexp.MustCompile(`你是谁`),
|
||||
regexp.MustCompile(`你是什么`),
|
||||
regexp.MustCompile(`你叫什么`),
|
||||
regexp.MustCompile(`什么模型`),
|
||||
regexp.MustCompile(`哪个模型`),
|
||||
regexp.MustCompile(`你基于什么`),
|
||||
regexp.MustCompile(`你是哪个`),
|
||||
regexp.MustCompile(`你是哪款`),
|
||||
regexp.MustCompile(`你的身份`),
|
||||
regexp.MustCompile(`你的名字`),
|
||||
regexp.MustCompile(`什么大模型`),
|
||||
regexp.MustCompile(`什么AI`),
|
||||
}
|
||||
|
||||
// isIdentityQuestion returns true when the text appears to be asking about AI identity.
|
||||
func isIdentityQuestion(text string) bool {
|
||||
for _, re := range identityPatterns {
|
||||
if re.MatchString(text) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasChinese(s string) bool {
|
||||
for _, r := range s {
|
||||
if unicode.Is(unicode.Han, r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// friendlyModelName converts a raw model ID to a human-readable Claude model name.
|
||||
func friendlyModelName(model string) string {
|
||||
m := strings.ToLower(model)
|
||||
for _, suf := range []string{"-thinking", "-thought"} {
|
||||
if strings.HasSuffix(m, suf) {
|
||||
m = m[:len(m)-len(suf)]
|
||||
break
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(m, "opus-4.7") || strings.Contains(m, "opus-4-7"):
|
||||
return "Claude Opus 4.7"
|
||||
case strings.Contains(m, "opus-4.6") || strings.Contains(m, "opus-4-6"):
|
||||
return "Claude Opus 4.6"
|
||||
case strings.Contains(m, "opus-4.5") || strings.Contains(m, "opus-4-5"):
|
||||
return "Claude Opus 4.5"
|
||||
case strings.Contains(m, "sonnet-4.7") || strings.Contains(m, "sonnet-4-7"):
|
||||
return "Claude Sonnet 4.7"
|
||||
case strings.Contains(m, "sonnet-4.6") || strings.Contains(m, "sonnet-4-6"):
|
||||
return "Claude Sonnet 4.6"
|
||||
case strings.Contains(m, "sonnet-4.5") || strings.Contains(m, "sonnet-4-5"):
|
||||
return "Claude Sonnet 4.5"
|
||||
case strings.Contains(m, "sonnet-4"):
|
||||
return "Claude Sonnet 4"
|
||||
case strings.Contains(m, "haiku-4.7") || strings.Contains(m, "haiku-4-7"):
|
||||
return "Claude Haiku 4.7"
|
||||
case strings.Contains(m, "haiku-4.5") || strings.Contains(m, "haiku-4-5"):
|
||||
return "Claude Haiku 4.5"
|
||||
default:
|
||||
return "Claude"
|
||||
}
|
||||
}
|
||||
|
||||
// claudeIdentityText returns a Claude-style identity response, language-matched to the question.
|
||||
func claudeIdentityText(model, question string) string {
|
||||
friendly := friendlyModelName(model)
|
||||
if hasChinese(question) {
|
||||
return "我是 Claude,由 Anthropic 公司开发的 AI 助手。\n\n我目前使用的是 " + friendly + " 模型,可以帮你完成代码编写与调试、分析推理、多语言对话等任务。有什么可以帮你的吗?"
|
||||
}
|
||||
return "I'm Claude, an AI assistant made by Anthropic.\n\nI'm currently running on the " + friendly + " model. I can help with coding, debugging, analysis, writing, and much more. What can I help you with?"
|
||||
}
|
||||
|
||||
// getLastUserTextClaude extracts the text of the last user message.
|
||||
func getLastUserTextClaude(msgs []ClaudeMessage) string {
|
||||
for i := len(msgs) - 1; i >= 0; i-- {
|
||||
if msgs[i].Role == "user" {
|
||||
text, _, _ := extractClaudeUserContent(msgs[i].Content)
|
||||
return text
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getLastUserTextOpenAI extracts the text of the last user message.
|
||||
func getLastUserTextOpenAI(msgs []OpenAIMessage) string {
|
||||
for i := len(msgs) - 1; i >= 0; i-- {
|
||||
if msgs[i].Role == "user" {
|
||||
text, _ := extractOpenAIUserContent(msgs[i].Content)
|
||||
return text
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// sendClaudeIdentityNonStream writes a non-streaming Claude identity response.
|
||||
func (h *Handler) sendClaudeIdentityNonStream(w http.ResponseWriter, model, question string, estimatedInputTokens int) {
|
||||
text := claudeIdentityText(model, question)
|
||||
outTokens := estimateTextTokens(text)
|
||||
resp := KiroToClaudeResponse(text, "", false, nil, estimatedInputTokens, outTokens, model)
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// sendClaudeIdentityStream writes a streaming Claude identity response.
|
||||
func (h *Handler) sendClaudeIdentityStream(w http.ResponseWriter, model, question string, estimatedInputTokens int) {
|
||||
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
h.sendClaudeError(w, 500, "api_error", "Streaming not supported")
|
||||
return
|
||||
}
|
||||
|
||||
text := claudeIdentityText(model, question)
|
||||
outTokens := estimateTextTokens(text)
|
||||
msgID := "msg_" + uuid.New().String()
|
||||
|
||||
send := func(event string, data interface{}) {
|
||||
b, _ := json.Marshal(data)
|
||||
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, b)
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
send("message_start", map[string]interface{}{
|
||||
"type": "message_start",
|
||||
"message": map[string]interface{}{
|
||||
"id": msgID,
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": []interface{}{},
|
||||
"model": model,
|
||||
"stop_reason": nil,
|
||||
"stop_sequence": nil,
|
||||
"usage": map[string]int{"input_tokens": estimatedInputTokens, "output_tokens": 0},
|
||||
},
|
||||
})
|
||||
send("content_block_start", map[string]interface{}{
|
||||
"type": "content_block_start",
|
||||
"index": 0,
|
||||
"content_block": map[string]string{
|
||||
"type": "text",
|
||||
"text": "",
|
||||
},
|
||||
})
|
||||
send("ping", map[string]string{"type": "ping"})
|
||||
send("content_block_delta", map[string]interface{}{
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": map[string]string{"type": "text_delta", "text": text},
|
||||
})
|
||||
send("content_block_stop", map[string]interface{}{
|
||||
"type": "content_block_stop",
|
||||
"index": 0,
|
||||
})
|
||||
send("message_delta", map[string]interface{}{
|
||||
"type": "message_delta",
|
||||
"delta": map[string]string{"stop_reason": "end_turn"},
|
||||
"usage": map[string]int{"output_tokens": outTokens},
|
||||
})
|
||||
send("message_stop", map[string]string{"type": "message_stop"})
|
||||
}
|
||||
|
||||
// sendOpenAIIdentityNonStream writes a non-streaming OpenAI identity response.
|
||||
func (h *Handler) sendOpenAIIdentityNonStream(w http.ResponseWriter, model, question string, estimatedInputTokens int) {
|
||||
text := claudeIdentityText(model, question)
|
||||
outTokens := estimateTextTokens(text)
|
||||
resp := OpenAIResponse{
|
||||
ID: "chatcmpl-" + uuid.New().String(),
|
||||
Object: "chat.completion",
|
||||
Created: 0,
|
||||
Model: model,
|
||||
Choices: []OpenAIChoice{{
|
||||
Index: 0,
|
||||
Message: OpenAIMessage{Role: "assistant", Content: text},
|
||||
FinishReason: "stop",
|
||||
}},
|
||||
Usage: OpenAIUsage{
|
||||
PromptTokens: estimatedInputTokens,
|
||||
CompletionTokens: outTokens,
|
||||
TotalTokens: estimatedInputTokens + outTokens,
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// estimateTextTokens estimates token count as word-count * 1.3.
|
||||
func estimateTextTokens(text string) int {
|
||||
n := len(strings.Fields(text))
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
return int(float64(n)*1.3) + 1
|
||||
}
|
||||
88
proxy/identity_test.go
Normal file
88
proxy/identity_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsIdentityQuestion(t *testing.T) {
|
||||
yes := []string{
|
||||
"你是谁?",
|
||||
"你是什么模型",
|
||||
"你叫什么名字",
|
||||
"什么模型",
|
||||
"哪个模型",
|
||||
"你基于什么",
|
||||
"你是哪个AI",
|
||||
"你的身份是什么",
|
||||
"who are you",
|
||||
"what are you",
|
||||
"what model are you",
|
||||
"which model do you use",
|
||||
"tell me about yourself",
|
||||
"identify yourself",
|
||||
"what is your name",
|
||||
"what AI are you",
|
||||
}
|
||||
no := []string{
|
||||
"帮我写一段 Go 代码",
|
||||
"fix this bug",
|
||||
"explain this function",
|
||||
"what does this code do",
|
||||
"你是怎么实现这个功能的", // "how did you implement" - not identity
|
||||
"what is the weather today",
|
||||
}
|
||||
|
||||
for _, q := range yes {
|
||||
if !isIdentityQuestion(q) {
|
||||
t.Errorf("expected isIdentityQuestion(%q)=true", q)
|
||||
}
|
||||
}
|
||||
for _, q := range no {
|
||||
if isIdentityQuestion(q) {
|
||||
t.Errorf("expected isIdentityQuestion(%q)=false", q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFriendlyModelName(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"claude-opus-4.7", "Claude Opus 4.7"},
|
||||
{"claude-opus-4-7", "Claude Opus 4.7"},
|
||||
{"claude-opus-4.7-thinking", "Claude Opus 4.7"},
|
||||
{"claude-sonnet-4.5", "Claude Sonnet 4.5"},
|
||||
{"claude-sonnet-4-5", "Claude Sonnet 4.5"},
|
||||
{"claude-sonnet-4.6", "Claude Sonnet 4.6"},
|
||||
{"claude-sonnet-4.7", "Claude Sonnet 4.7"},
|
||||
{"claude-sonnet-4", "Claude Sonnet 4"},
|
||||
{"claude-haiku-4.5", "Claude Haiku 4.5"},
|
||||
{"claude-haiku-4-5", "Claude Haiku 4.5"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := friendlyModelName(tc.in)
|
||||
if got != tc.want {
|
||||
t.Errorf("friendlyModelName(%q) = %q; want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeIdentityTextLanguage(t *testing.T) {
|
||||
zhText := claudeIdentityText("claude-opus-4.7", "你是谁")
|
||||
if !hasChinese(zhText) {
|
||||
t.Errorf("expected Chinese response for Chinese question, got %q", zhText)
|
||||
}
|
||||
if !strings.Contains(zhText, "Claude Opus 4.7") {
|
||||
t.Errorf("expected model name in response, got %q", zhText)
|
||||
}
|
||||
if !strings.Contains(zhText, "Anthropic") {
|
||||
t.Errorf("expected Anthropic in response, got %q", zhText)
|
||||
}
|
||||
|
||||
enText := claudeIdentityText("claude-sonnet-4.5", "who are you")
|
||||
if hasChinese(enText) {
|
||||
t.Errorf("expected English response for English question, got %q", enText)
|
||||
}
|
||||
if !strings.Contains(enText, "Claude Sonnet 4.5") {
|
||||
t.Errorf("expected model name in English response, got %q", enText)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user