mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-08-17 19:47:10 +08:00
v0.3.8
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
package modeladapter
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAnthropicMessageCacheBreakpointsPreserveAppendOnlyHistory(t *testing.T) {
|
||||
for size := 1; size <= 32; size++ {
|
||||
t.Run(fmt.Sprintf("size_%02d", size), func(t *testing.T) {
|
||||
previous := anthropicMessagesForAppendOnlyTest(size)
|
||||
current := anthropicMessagesForAppendOnlyTest(size + 1)
|
||||
|
||||
applyAnthropicMessageCacheBreakpoints(previous)
|
||||
applyAnthropicMessageCacheBreakpoints(current)
|
||||
|
||||
want := mustMarshalAnthropicMessagesForTest(t, previous)
|
||||
got := mustMarshalAnthropicMessagesForTest(t, current[:len(previous)])
|
||||
if got != want {
|
||||
t.Fatalf("historical message prefix changed after append\nwant: %s\ngot: %s", want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func anthropicMessagesForAppendOnlyTest(count int) []anthropicMessage {
|
||||
messages := make([]anthropicMessage, 0, count)
|
||||
for index := 0; index < count; index++ {
|
||||
messages = append(messages, anthropicMessage{
|
||||
Role: "user",
|
||||
Content: []map[string]any{{
|
||||
"type": "text",
|
||||
"text": fmt.Sprintf("message-%02d", index),
|
||||
}},
|
||||
})
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
func mustMarshalAnthropicMessagesForTest(t *testing.T, messages []anthropicMessage) string {
|
||||
t.Helper()
|
||||
payload, err := json.Marshal(messages)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal anthropic messages: %v", err)
|
||||
}
|
||||
return string(payload)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package modeladapter
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// recordLLMRequestArtifact 记录一次模型调用的原始请求工件。
|
||||
func recordLLMRequestArtifact(req StreamRequest, provider string, model string, method string, url string, body any) {
|
||||
if req.Observer == nil {
|
||||
return
|
||||
}
|
||||
path, err := req.Observer.RecordLLMRequest(req.RequestID, req.RunID, req.ModelCallID, map[string]any{
|
||||
"request_id": req.RequestID,
|
||||
"run_id": req.RunID,
|
||||
"model_call_id": req.ModelCallID,
|
||||
"provider": strings.TrimSpace(provider),
|
||||
"openai_endpoint": strings.TrimSpace(req.OpenAIEndpoint),
|
||||
"model": firstNonEmptyString(model, req.ModelID),
|
||||
"runtime_model_id": strings.TrimSpace(req.ModelID),
|
||||
"resolved_channel_id": strings.TrimSpace(req.ResolvedChannelID),
|
||||
"resolved_channel_name": strings.TrimSpace(req.ResolvedChannelName),
|
||||
"resolved_context_window_tokens": req.ResolvedContextWindowTokens,
|
||||
"url": url,
|
||||
"method": method,
|
||||
"body": body,
|
||||
"request_knobs": req.RequestKnobs,
|
||||
"compile_summary": req.CompileSummary,
|
||||
"stable_message_count": req.StableMessageCount,
|
||||
"tools_summary": summarizeTools(req.Tools),
|
||||
"messages_summary": summarizeMessages(req.Messages),
|
||||
})
|
||||
if err == nil && req.ArtifactPaths != nil {
|
||||
req.ArtifactPaths.RequestPath = path
|
||||
}
|
||||
}
|
||||
|
||||
// buildLLMSummaryPayload 生成 LLM 调用摘要工件内容。
|
||||
func buildLLMSummaryPayload(
|
||||
req StreamRequest,
|
||||
provider string,
|
||||
model string,
|
||||
startedAt time.Time,
|
||||
firstEventAt time.Time,
|
||||
finishedAt time.Time,
|
||||
finishReason string,
|
||||
inputTokens int64,
|
||||
outputTokens int64,
|
||||
cacheReadTokens int64,
|
||||
cacheWriteTokens int64,
|
||||
err error,
|
||||
) map[string]any {
|
||||
finished := finishedAt
|
||||
if finished.IsZero() {
|
||||
finished = time.Now().UTC()
|
||||
}
|
||||
promptTokensTotal := inputTokens + cacheReadTokens + cacheWriteTokens
|
||||
requestTokensTotal := promptTokensTotal + outputTokens
|
||||
return map[string]any{
|
||||
"provider": strings.TrimSpace(provider),
|
||||
"model": strings.TrimSpace(model),
|
||||
"started_at": normalizeModelArtifactTime(startedAt),
|
||||
"first_event_at": normalizeModelArtifactTime(firstEventAt),
|
||||
"finished_at": normalizeModelArtifactTime(finished),
|
||||
"finish_reason": strings.TrimSpace(finishReason),
|
||||
"input_tokens": inputTokens,
|
||||
"output_tokens": outputTokens,
|
||||
"cache_read_tokens": cacheReadTokens,
|
||||
"cache_write_tokens": cacheWriteTokens,
|
||||
"prompt_tokens_total": promptTokensTotal,
|
||||
"request_tokens_total": requestTokensTotal,
|
||||
"error": summarizeModelArtifactError(err),
|
||||
"ttft_ms": computeTTFTMS(startedAt, firstEventAt),
|
||||
"duration_ms": computeDurationMS(startedAt, finished),
|
||||
}
|
||||
}
|
||||
|
||||
// appendLLMResponseArtifact 追加模型调用的原始响应文本。
|
||||
func appendLLMResponseArtifact(req StreamRequest, chunk string) (string, error) {
|
||||
if req.Observer == nil {
|
||||
return "", nil
|
||||
}
|
||||
path, err := req.Observer.AppendLLMResponseChunk(req.RequestID, req.RunID, req.ModelCallID, chunk)
|
||||
if err == nil && req.ArtifactPaths != nil {
|
||||
req.ArtifactPaths.ResponsePath = path
|
||||
}
|
||||
return path, err
|
||||
}
|
||||
|
||||
// recordLLMSummaryArtifact 记录模型调用摘要。
|
||||
func recordLLMSummaryArtifact(req StreamRequest, payload map[string]any) {
|
||||
if req.Observer == nil {
|
||||
return
|
||||
}
|
||||
path, err := req.Observer.RecordLLMSummary(req.RequestID, req.RunID, req.ModelCallID, payload)
|
||||
if err == nil && req.ArtifactPaths != nil {
|
||||
req.ArtifactPaths.SummaryPath = path
|
||||
}
|
||||
}
|
||||
|
||||
// summarizeTools 生成工具列表摘要。
|
||||
func summarizeTools(items []json.RawMessage) []string {
|
||||
result := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
var wrapper struct {
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"function"`
|
||||
}
|
||||
if err := json.Unmarshal(item, &wrapper); err != nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(wrapper.Function.Name) != "" {
|
||||
result = append(result, strings.TrimSpace(wrapper.Function.Name))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// summarizeMessages 生成消息列表摘要。
|
||||
func summarizeMessages(items []Message) []map[string]any {
|
||||
result := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
content := truncateArtifactText(item.Content, 120)
|
||||
imageCount := 0
|
||||
for _, part := range item.ContentParts {
|
||||
if normalizeContentPartType(part.Type) == contentPartTypeImage {
|
||||
imageCount++
|
||||
}
|
||||
}
|
||||
result = append(result, map[string]any{
|
||||
"role": item.Role,
|
||||
"content_preview": content,
|
||||
"content_length": len([]rune(content)),
|
||||
"image_count": imageCount,
|
||||
"tool_call_count": len(item.ToolCalls),
|
||||
"tool_call_id": item.ToolCallID,
|
||||
"name": item.Name,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func truncateArtifactText(text string, maxRunes int) string {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed == "" || maxRunes <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(trimmed)
|
||||
if len(runes) <= maxRunes {
|
||||
return trimmed
|
||||
}
|
||||
return string(runes[:maxRunes]) + "..."
|
||||
}
|
||||
|
||||
// normalizeModelArtifactTime 把时间格式化为 RFC3339Nano。
|
||||
func normalizeModelArtifactTime(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return value.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
// computeTTFTMS 计算首事件耗时。
|
||||
func computeTTFTMS(startedAt time.Time, firstEventAt time.Time) int64 {
|
||||
if startedAt.IsZero() || firstEventAt.IsZero() {
|
||||
return 0
|
||||
}
|
||||
value := firstEventAt.Sub(startedAt).Milliseconds()
|
||||
if value < 0 {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// computeDurationMS 计算调用总耗时。
|
||||
func computeDurationMS(startedAt time.Time, finishedAt time.Time) int64 {
|
||||
if startedAt.IsZero() || finishedAt.IsZero() {
|
||||
return 0
|
||||
}
|
||||
value := finishedAt.Sub(startedAt).Milliseconds()
|
||||
if value < 0 {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// summarizeModelArtifactError 返回可安全落盘的错误文本。
|
||||
func summarizeModelArtifactError(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(err.Error())
|
||||
}
|
||||
|
||||
// firstNonEmptyString 返回第一个非空字符串。
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package modeladapter
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
contentPartTypeText = "text"
|
||||
contentPartTypeImage = "image"
|
||||
)
|
||||
|
||||
// ContentPart 表示一条消息中的结构化内容块。
|
||||
type ContentPart struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Image *ImageContent `json:"image,omitempty"`
|
||||
}
|
||||
|
||||
// ImageContent 表示消息中携带的一张图片。
|
||||
type ImageContent struct {
|
||||
MIMEType string `json:"mime_type,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Data []byte `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func hasImageContentParts(parts []ContentPart) bool {
|
||||
for _, part := range parts {
|
||||
if normalizeContentPartType(part.Type) == contentPartTypeImage {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func collapseTextContentParts(parts []ContentPart) string {
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
texts := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if normalizeContentPartType(part.Type) != contentPartTypeText {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(part.Text) == "" {
|
||||
continue
|
||||
}
|
||||
texts = append(texts, part.Text)
|
||||
}
|
||||
return strings.Join(texts, "")
|
||||
}
|
||||
|
||||
func normalizeContentPartType(value string) string {
|
||||
trimmed := strings.TrimSpace(strings.ToLower(value))
|
||||
switch trimmed {
|
||||
case "", contentPartTypeText:
|
||||
return contentPartTypeText
|
||||
case contentPartTypeImage:
|
||||
return contentPartTypeImage
|
||||
default:
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
func openAIContentValue(message Message) (any, error) {
|
||||
if !hasImageContentParts(message.ContentParts) {
|
||||
content := message.Content
|
||||
if strings.TrimSpace(content) == "" && len(message.ContentParts) > 0 {
|
||||
content = collapseTextContentParts(message.ContentParts)
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
parts := make([]map[string]any, 0, len(message.ContentParts)+1)
|
||||
if len(message.ContentParts) == 0 && strings.TrimSpace(message.Content) != "" {
|
||||
parts = append(parts, map[string]any{
|
||||
"type": contentPartTypeText,
|
||||
"text": message.Content,
|
||||
})
|
||||
}
|
||||
for _, part := range message.ContentParts {
|
||||
switch normalizeContentPartType(part.Type) {
|
||||
case contentPartTypeText:
|
||||
if part.Text == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, map[string]any{
|
||||
"type": contentPartTypeText,
|
||||
"text": part.Text,
|
||||
})
|
||||
case contentPartTypeImage:
|
||||
dataURL, err := imageContentDataURL(part.Image)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parts = append(parts, map[string]any{
|
||||
"type": "image_url",
|
||||
"image_url": map[string]any{
|
||||
"url": dataURL,
|
||||
},
|
||||
})
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported openai content part type: %s", strings.TrimSpace(part.Type))
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return message.Content, nil
|
||||
}
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
func anthropicContentBlocks(message Message) ([]map[string]any, error) {
|
||||
if len(message.ContentParts) == 0 {
|
||||
if strings.TrimSpace(message.Content) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return []map[string]any{{
|
||||
"type": contentPartTypeText,
|
||||
"text": message.Content,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
blocks := make([]map[string]any, 0, len(message.ContentParts))
|
||||
for _, part := range message.ContentParts {
|
||||
switch normalizeContentPartType(part.Type) {
|
||||
case contentPartTypeText:
|
||||
if part.Text == "" {
|
||||
continue
|
||||
}
|
||||
blocks = append(blocks, map[string]any{
|
||||
"type": contentPartTypeText,
|
||||
"text": part.Text,
|
||||
})
|
||||
case contentPartTypeImage:
|
||||
payload, mediaType, err := resolveImageContent(part.Image)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blocks = append(blocks, map[string]any{
|
||||
"type": contentPartTypeImage,
|
||||
"source": map[string]any{
|
||||
"type": "base64",
|
||||
"media_type": mediaType,
|
||||
"data": base64.StdEncoding.EncodeToString(payload),
|
||||
},
|
||||
})
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported anthropic content part type: %s", strings.TrimSpace(part.Type))
|
||||
}
|
||||
}
|
||||
if len(blocks) == 0 && strings.TrimSpace(message.Content) != "" {
|
||||
blocks = append(blocks, map[string]any{
|
||||
"type": contentPartTypeText,
|
||||
"text": message.Content,
|
||||
})
|
||||
}
|
||||
return blocks, nil
|
||||
}
|
||||
|
||||
func imageContentDataURL(image *ImageContent) (string, error) {
|
||||
payload, mediaType, err := resolveImageContent(image)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "data:" + mediaType + ";base64," + base64.StdEncoding.EncodeToString(payload), nil
|
||||
}
|
||||
|
||||
func resolveImageContent(image *ImageContent) ([]byte, string, error) {
|
||||
if image == nil {
|
||||
return nil, "", fmt.Errorf("image content is required")
|
||||
}
|
||||
if len(image.Data) > 0 {
|
||||
return image.Data, normalizeImageMIMEType(image.MIMEType, image.Path, image.Data), nil
|
||||
}
|
||||
path := strings.TrimSpace(image.Path)
|
||||
if path == "" {
|
||||
return nil, "", fmt.Errorf("image content is missing data and path")
|
||||
}
|
||||
payload, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("read image content failed: %w", err)
|
||||
}
|
||||
return payload, normalizeImageMIMEType(image.MIMEType, path, payload), nil
|
||||
}
|
||||
|
||||
func normalizeImageMIMEType(mimeType string, path string, payload []byte) string {
|
||||
trimmed := strings.TrimSpace(strings.ToLower(mimeType))
|
||||
if trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
if len(payload) > 0 {
|
||||
detected := strings.TrimSpace(strings.ToLower(http.DetectContentType(payload)))
|
||||
if strings.HasPrefix(detected, "image/") {
|
||||
return detected
|
||||
}
|
||||
}
|
||||
switch strings.ToLower(filepath.Ext(strings.TrimSpace(path))) {
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".gif":
|
||||
return "image/gif"
|
||||
case ".webp":
|
||||
return "image/webp"
|
||||
default:
|
||||
return "image/png"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package modeladapter
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
|
||||
"cursor/gen/agentv1"
|
||||
runtimecore "cursor/internal/backend/agent/core"
|
||||
)
|
||||
|
||||
func emitCreatePlanToolProgress(
|
||||
sink func(ModelEvent) error,
|
||||
provider string,
|
||||
model string,
|
||||
callID string,
|
||||
rawArgs string,
|
||||
argsTextDelta string,
|
||||
lastSnapshot *string,
|
||||
) error {
|
||||
if sink == nil || lastSnapshot == nil {
|
||||
return nil
|
||||
}
|
||||
trimmedCallID := strings.TrimSpace(callID)
|
||||
if trimmedCallID == "" {
|
||||
return nil
|
||||
}
|
||||
args, ok := createPlanArgsProgressSnapshot(rawArgs)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
signatureBytes, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
signature := string(signatureBytes)
|
||||
if signature == "" || signature == *lastSnapshot {
|
||||
return nil
|
||||
}
|
||||
*lastSnapshot = signature
|
||||
if err := sink(ModelEvent{
|
||||
Kind: ModelEventKindPartialToolCall,
|
||||
OccurredAt: time.Now().UTC(),
|
||||
Provider: provider,
|
||||
Model: model,
|
||||
ToolCallID: trimmedCallID,
|
||||
ArgsTextDelta: argsTextDelta,
|
||||
ToolCall: &agentv1.ToolCall{
|
||||
Tool: &agentv1.ToolCall_CreatePlanToolCall{
|
||||
CreatePlanToolCall: &agentv1.CreatePlanToolCall{
|
||||
Args: args,
|
||||
},
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func createPlanArgsProgressSnapshot(rawArgs string) (*agentv1.CreatePlanArgs, bool) {
|
||||
trimmed := strings.TrimSpace(rawArgs)
|
||||
if trimmed == "" {
|
||||
return nil, false
|
||||
}
|
||||
if args, err := runtimecore.DecodeCreatePlanArgsJSON([]byte(trimmed)); err == nil && hasCreatePlanArgsProgress(args) {
|
||||
return args, true
|
||||
}
|
||||
|
||||
args := &agentv1.CreatePlanArgs{}
|
||||
if value, found, _ := extractJSONStringFieldPrefix(trimmed, "plan"); found {
|
||||
args.Plan = value
|
||||
}
|
||||
if value, found, _ := extractJSONStringFieldPrefix(trimmed, "overview"); found {
|
||||
args.Overview = value
|
||||
}
|
||||
if value, found, complete := extractJSONStringFieldPrefix(trimmed, "name"); found && complete {
|
||||
args.Name = strings.TrimSpace(value)
|
||||
}
|
||||
if !hasCreatePlanArgsProgress(args) {
|
||||
return nil, false
|
||||
}
|
||||
return args, true
|
||||
}
|
||||
|
||||
func hasCreatePlanArgsProgress(args *agentv1.CreatePlanArgs) bool {
|
||||
if args == nil {
|
||||
return false
|
||||
}
|
||||
return args.GetPlan() != "" ||
|
||||
args.GetOverview() != "" ||
|
||||
args.GetName() != "" ||
|
||||
args.GetIsProject() ||
|
||||
len(args.GetTodos()) > 0 ||
|
||||
len(args.GetPhases()) > 0
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package modeladapter 提供 OpenAI / Anthropic 兼容流式模型适配器与路由器。
|
||||
package modeladapter
|
||||
@@ -0,0 +1,41 @@
|
||||
// http_error.go 负责把非 2xx HTTP 响应整理成带响应体摘要的错误。
|
||||
package modeladapter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxErrorBodyBytes 表示错误响应体最多读取的字节数。
|
||||
maxErrorBodyBytes = 8192
|
||||
)
|
||||
|
||||
// buildHTTPStatusError 读取响应体摘要并生成带状态码的错误。
|
||||
func buildHTTPStatusError(prefix string, resp *http.Response) error {
|
||||
if resp == nil {
|
||||
return fmt.Errorf("%s response is nil", strings.TrimSpace(prefix))
|
||||
}
|
||||
|
||||
limitedBody, err := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes))
|
||||
if err != nil {
|
||||
if retrySummary := ProviderRetryAttemptSummary(resp); retrySummary != "" {
|
||||
return fmt.Errorf("%s status=%d %s body_read_error=%v", strings.TrimSpace(prefix), resp.StatusCode, retrySummary, err)
|
||||
}
|
||||
return fmt.Errorf("%s status=%d body_read_error=%v", strings.TrimSpace(prefix), resp.StatusCode, err)
|
||||
}
|
||||
retrySummary := ProviderRetryAttemptSummary(resp)
|
||||
bodyText := strings.TrimSpace(string(limitedBody))
|
||||
if bodyText == "" {
|
||||
if retrySummary != "" {
|
||||
return fmt.Errorf("%s status=%d %s", strings.TrimSpace(prefix), resp.StatusCode, retrySummary)
|
||||
}
|
||||
return fmt.Errorf("%s status=%d", strings.TrimSpace(prefix), resp.StatusCode)
|
||||
}
|
||||
if retrySummary != "" {
|
||||
return fmt.Errorf("%s status=%d %s body=%s", strings.TrimSpace(prefix), resp.StatusCode, retrySummary, bodyText)
|
||||
}
|
||||
return fmt.Errorf("%s status=%d body=%s", strings.TrimSpace(prefix), resp.StatusCode, bodyText)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
package modeladapter
|
||||
|
||||
func maxAnthropicTokens(req StreamRequest) int {
|
||||
if req.AnthropicMaxTokens > 0 {
|
||||
return req.AnthropicMaxTokens
|
||||
}
|
||||
if req.MaxTokens > 0 {
|
||||
return req.MaxTokens
|
||||
}
|
||||
return 65536
|
||||
}
|
||||
|
||||
func maxThinkingBudget(req StreamRequest) int {
|
||||
if req.ThinkingBudgetTokens > 0 {
|
||||
return req.ThinkingBudgetTokens
|
||||
}
|
||||
return anthropicThinkingBudget(maxAnthropicTokens(req))
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package modeladapter
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func cloneRequestBodyOverride(input map[string]any) map[string]any {
|
||||
if len(input) == 0 {
|
||||
return nil
|
||||
}
|
||||
payload, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var cloned map[string]any
|
||||
if err := json.Unmarshal(payload, &cloned); err != nil {
|
||||
return nil
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func requestBodyToMap(input any) (map[string]any, error) {
|
||||
if body, ok := input.(map[string]any); ok {
|
||||
return body, nil
|
||||
}
|
||||
payload, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(payload, &body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if body == nil {
|
||||
body = map[string]any{}
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func ApplyOpenAIExtraParams(body map[string]any, enabled bool, paramsJSON string) error {
|
||||
return applyExtraParams(body, enabled, paramsJSON, "openai extra params json")
|
||||
}
|
||||
|
||||
func ApplyAnthropicExtraParams(body map[string]any, enabled bool, paramsJSON string) error {
|
||||
return applyExtraParams(body, enabled, paramsJSON, "anthropic extra params json")
|
||||
}
|
||||
|
||||
func applyExtraParams(body map[string]any, enabled bool, paramsJSON string, label string) error {
|
||||
if !enabled {
|
||||
return nil
|
||||
}
|
||||
if body == nil {
|
||||
return fmt.Errorf("%s target body is nil", label)
|
||||
}
|
||||
extraParams, err := parseJSONMap(paramsJSON, label)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for key, value := range extraParams {
|
||||
name := strings.TrimSpace(key)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
body[name] = value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ApplyCustomHeaders(httpReq *http.Request, enabled bool, headersJSON string) error {
|
||||
if !enabled {
|
||||
return nil
|
||||
}
|
||||
if httpReq == nil {
|
||||
return fmt.Errorf("custom headers target request is nil")
|
||||
}
|
||||
headers, err := parseStringJSONMap(headersJSON, "custom headers json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for key, value := range headers {
|
||||
name := strings.TrimSpace(key)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
httpReq.Header.Set(name, value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseJSONMap(value string, label string) (map[string]any, error) {
|
||||
text := strings.TrimSpace(value)
|
||||
if text == "" {
|
||||
return nil, fmt.Errorf("%s is empty", label)
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &parsed); err != nil {
|
||||
return nil, fmt.Errorf("%s must be an object: %w", label, err)
|
||||
}
|
||||
if parsed == nil {
|
||||
return nil, fmt.Errorf("%s must be an object", label)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func parseStringJSONMap(value string, label string) (map[string]string, error) {
|
||||
text := strings.TrimSpace(value)
|
||||
if text == "" {
|
||||
return nil, fmt.Errorf("%s is empty", label)
|
||||
}
|
||||
var parsed map[string]string
|
||||
if err := json.Unmarshal([]byte(text), &parsed); err != nil {
|
||||
return nil, fmt.Errorf("%s must be an object with string values: %w", label, err)
|
||||
}
|
||||
if parsed == nil {
|
||||
return nil, fmt.Errorf("%s must be an object", label)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// retry.go 保留 provider HTTP 请求入口的历史命名;provider 错误交给客户端重连链路处理。
|
||||
package modeladapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// DoProviderRequestWithRetry 保留旧入口名;本地模式不在服务端重试 provider 请求。
|
||||
func DoProviderRequestWithRetry(
|
||||
ctx context.Context,
|
||||
client *http.Client,
|
||||
provider string,
|
||||
requestID string,
|
||||
modelCallID string,
|
||||
buildRequest func(context.Context) (*http.Request, error),
|
||||
) (*http.Response, error) {
|
||||
return doProviderRequestWithRetry(ctx, client, provider, requestID, modelCallID, buildRequest)
|
||||
}
|
||||
|
||||
func doProviderRequestWithRetry(
|
||||
ctx context.Context,
|
||||
client *http.Client,
|
||||
provider string,
|
||||
requestID string,
|
||||
modelCallID string,
|
||||
buildRequest func(context.Context) (*http.Request, error),
|
||||
) (*http.Response, error) {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
httpReq, err := buildRequest(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
if resp != nil && resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ProviderRetryAttemptSummary 返回空值;provider 请求不再有服务端内部重试摘要。
|
||||
func ProviderRetryAttemptSummary(resp *http.Response) string {
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
// router.go 按模型标识选择 OpenAI 或 Anthropic 兼容适配器。
|
||||
package modeladapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
legacyruntime "cursor/internal/runtime"
|
||||
)
|
||||
|
||||
// Router 是 MVP 阶段的模型适配路由器。
|
||||
type Router struct {
|
||||
// openai 负责 OpenAI 兼容流式请求。
|
||||
openai ModelAdapter
|
||||
// anthropic 负责 Anthropic 兼容流式请求。
|
||||
anthropic ModelAdapter
|
||||
// resolver 负责从本地配置中解析实际模型通道。
|
||||
resolver ChannelResolver
|
||||
}
|
||||
|
||||
type ChannelResolver interface {
|
||||
SelectChannelForModel(context.Context, string) (*legacyruntime.ResolvedChannel, error)
|
||||
ProviderStreamIdleTimeout(context.Context) time.Duration
|
||||
}
|
||||
|
||||
// NewRouter 创建模型适配路由器。
|
||||
func NewRouter(resolver ChannelResolver) *Router {
|
||||
return &Router{
|
||||
openai: NewOpenAIAdapter(),
|
||||
anthropic: NewAnthropicAdapter(),
|
||||
resolver: resolver,
|
||||
}
|
||||
}
|
||||
|
||||
// Stream 根据模型标识选择具体 provider 并转发请求。
|
||||
func (router *Router) Stream(ctx context.Context, req StreamRequest, sink func(ModelEvent) error) error {
|
||||
if router == nil || router.resolver == nil {
|
||||
return fmt.Errorf("model adapter resolver is unavailable")
|
||||
}
|
||||
channel, err := router.resolver.SelectChannelForModel(ctx, req.ModelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if channel == nil {
|
||||
return fmt.Errorf("no available channel for model %q", req.ModelID)
|
||||
}
|
||||
|
||||
resolved := req
|
||||
resolved.Provider = strings.TrimSpace(channel.Provider)
|
||||
resolved.BaseURL = strings.TrimSpace(channel.BaseURL)
|
||||
resolved.APIKey = strings.TrimSpace(channel.APIKey)
|
||||
resolved.ProviderModelID = strings.TrimSpace(channel.Model)
|
||||
resolved.ResolvedChannelID = strings.TrimSpace(channel.ID)
|
||||
resolved.ResolvedChannelName = strings.TrimSpace(channel.Name)
|
||||
resolved.ResolvedContextWindowTokens = channel.ContextWindowTokens
|
||||
resolved.ReasoningEffort = openAIReasoningEffortFromRuntime(channel.ReasoningEffort)
|
||||
resolved.OpenAIEndpoint = strings.TrimSpace(channel.OpenAIEndpoint)
|
||||
resolved.OpenAIExtraParamsEnabled = channel.OpenAIExtraParamsEnabled
|
||||
resolved.OpenAIExtraParamsJSON = strings.TrimSpace(channel.OpenAIExtraParamsJSON)
|
||||
resolved.CustomHeadersEnabled = channel.CustomHeadersEnabled
|
||||
resolved.CustomHeadersJSON = strings.TrimSpace(channel.CustomHeadersJSON)
|
||||
resolved.AnthropicExtraParamsEnabled = channel.AnthropicExtraParamsEnabled
|
||||
resolved.AnthropicExtraParamsJSON = strings.TrimSpace(channel.AnthropicExtraParamsJSON)
|
||||
resolved.AnthropicMaxTokens = channel.AnthropicMaxTokens
|
||||
resolved.AnthropicThinkingEffort = strings.TrimSpace(channel.AnthropicThinkingEffort)
|
||||
resolved.ThinkingBudgetTokens = channel.ThinkingBudgetTokens
|
||||
resolved.ProviderStreamIdleTimeout = router.resolver.ProviderStreamIdleTimeout(ctx)
|
||||
runtimeThinkingEffort := normalizeRuntimeThinkingEffort(req.ThinkingEffort)
|
||||
if runtimeThinkingEffort != "" {
|
||||
resolved.ThinkingEffort = runtimeThinkingEffort
|
||||
if runtimeThinkingEffort == "disabled" {
|
||||
resolved.ReasoningEffort = ""
|
||||
resolved.AnthropicThinkingEffort = ""
|
||||
} else {
|
||||
resolved.ReasoningEffort = openAIReasoningEffortFromRuntime(runtimeThinkingEffort)
|
||||
resolved.AnthropicThinkingEffort = runtimeThinkingEffort
|
||||
}
|
||||
} else {
|
||||
resolved.ThinkingEffort = ""
|
||||
}
|
||||
if resolved.MaxTokens <= 0 && channel.MaxTokens > 0 {
|
||||
resolved.MaxTokens = channel.MaxTokens
|
||||
}
|
||||
if req.MaxTokens > 0 && (resolved.AnthropicMaxTokens <= 0 || req.MaxTokens < resolved.AnthropicMaxTokens) {
|
||||
resolved.AnthropicMaxTokens = req.MaxTokens
|
||||
}
|
||||
if resolved.AnthropicMaxTokens <= 0 && resolved.MaxTokens > 0 {
|
||||
resolved.AnthropicMaxTokens = resolved.MaxTokens
|
||||
}
|
||||
if resolved.ProviderModelID == "" {
|
||||
resolved.ProviderModelID = strings.TrimSpace(req.ModelID)
|
||||
}
|
||||
resolved.Messages = sanitizeProviderMessages(req.Messages)
|
||||
if resolved.RequestKnobs != nil {
|
||||
resolved.RequestKnobs["max_tokens"] = resolved.MaxTokens
|
||||
if runtimeThinkingEffort != "" {
|
||||
resolved.RequestKnobs["runtime_thinking_effort"] = runtimeThinkingEffort
|
||||
} else {
|
||||
delete(resolved.RequestKnobs, "runtime_thinking_effort")
|
||||
}
|
||||
if resolved.Provider == "openai" {
|
||||
if strings.TrimSpace(resolved.ReasoningEffort) != "" {
|
||||
resolved.RequestKnobs["reasoning_effort"] = strings.TrimSpace(resolved.ReasoningEffort)
|
||||
} else {
|
||||
delete(resolved.RequestKnobs, "reasoning_effort")
|
||||
}
|
||||
resolved.RequestKnobs["openai_endpoint"] = resolved.OpenAIEndpoint
|
||||
resolved.RequestKnobs["openai_extra_params_enabled"] = resolved.OpenAIExtraParamsEnabled
|
||||
resolved.RequestKnobs["custom_headers_enabled"] = resolved.CustomHeadersEnabled
|
||||
} else if resolved.Provider == "anthropic" {
|
||||
delete(resolved.RequestKnobs, "reasoning_effort")
|
||||
resolved.RequestKnobs["custom_headers_enabled"] = resolved.CustomHeadersEnabled
|
||||
resolved.RequestKnobs["anthropic_extra_params_enabled"] = resolved.AnthropicExtraParamsEnabled
|
||||
anthropicMaxTokens := maxAnthropicTokens(resolved)
|
||||
resolved.RequestKnobs["max_tokens"] = anthropicMaxTokens
|
||||
resolved.RequestKnobs["anthropic_max_tokens"] = anthropicMaxTokens
|
||||
if strings.TrimSpace(resolved.AnthropicThinkingEffort) != "" {
|
||||
resolved.RequestKnobs["anthropic_thinking_effort"] = anthropicThinkingEffort(resolved)
|
||||
} else {
|
||||
delete(resolved.RequestKnobs, "anthropic_thinking_effort")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch resolved.Provider {
|
||||
case "anthropic":
|
||||
return router.anthropic.Stream(ctx, resolved, sink)
|
||||
case "openai":
|
||||
return router.openai.Stream(ctx, resolved, sink)
|
||||
default:
|
||||
return fmt.Errorf("unsupported provider %q", resolved.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeProviderMessages removes replay-only placeholders and trims trailing
|
||||
// assistant prefill so providers that require a user/tool terminal message do
|
||||
// not reject the request.
|
||||
func sanitizeProviderMessages(input []Message) []Message {
|
||||
if len(input) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
filtered := make([]Message, 0, len(input))
|
||||
for _, message := range input {
|
||||
if isAssistantPlaceholderMessage(message) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, message)
|
||||
}
|
||||
filtered = mergeAdjacentAssistantToolCallMessages(filtered)
|
||||
filtered = trimDanglingAssistantToolCalls(filtered)
|
||||
for len(filtered) > 0 && isAssistantPrefillMessage(filtered[len(filtered)-1]) {
|
||||
filtered = filtered[:len(filtered)-1]
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return nil
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func isAssistantPlaceholderMessage(message Message) bool {
|
||||
if strings.TrimSpace(message.Role) != "assistant" {
|
||||
return false
|
||||
}
|
||||
if len(message.ToolCalls) > 0 || len(message.ContentParts) > 0 {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(message.ToolCallID) != "" || strings.TrimSpace(message.Name) != "" {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(message.ReasoningContent) != "" {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(message.ReasoningSignature) != "" {
|
||||
return false
|
||||
}
|
||||
switch strings.TrimSpace(message.Content) {
|
||||
case "":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isAssistantPrefillMessage(message Message) bool {
|
||||
if strings.TrimSpace(message.Role) != "assistant" {
|
||||
return false
|
||||
}
|
||||
if len(message.ToolCalls) > 0 {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(message.ToolCallID) != "" || strings.TrimSpace(message.Name) != "" {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(message.Content) != "" || strings.TrimSpace(message.ReasoningContent) != ""
|
||||
}
|
||||
|
||||
func mergeAdjacentAssistantToolCallMessages(input []Message) []Message {
|
||||
if len(input) == 0 {
|
||||
return nil
|
||||
}
|
||||
merged := make([]Message, 0, len(input))
|
||||
for _, raw := range input {
|
||||
message := cloneProviderMessage(raw)
|
||||
if mergeProviderAssistantToolCalls(&merged, message) {
|
||||
continue
|
||||
}
|
||||
merged = append(merged, message)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func cloneProviderMessage(message Message) Message {
|
||||
cloned := message
|
||||
if len(message.ContentParts) > 0 {
|
||||
cloned.ContentParts = append([]ContentPart(nil), message.ContentParts...)
|
||||
}
|
||||
if len(message.ToolCalls) > 0 {
|
||||
cloned.ToolCalls = append([]ToolCallDescriptor(nil), message.ToolCalls...)
|
||||
}
|
||||
if len(message.OpenAIResponsesReasoningSummary) > 0 {
|
||||
cloned.OpenAIResponsesReasoningSummary = append([]byte(nil), message.OpenAIResponsesReasoningSummary...)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func mergeProviderAssistantToolCalls(messages *[]Message, message Message) bool {
|
||||
if len(*messages) == 0 {
|
||||
return false
|
||||
}
|
||||
last := &(*messages)[len(*messages)-1]
|
||||
if !canMergeProviderAssistantToolCalls(*last, message) {
|
||||
return false
|
||||
}
|
||||
startIndex := len(last.ToolCalls)
|
||||
for index, toolCall := range message.ToolCalls {
|
||||
item := toolCall
|
||||
item.Index = startIndex + index
|
||||
last.ToolCalls = append(last.ToolCalls, item)
|
||||
}
|
||||
last.ReasoningContent = mergeProviderReasoning(last.ReasoningContent, message.ReasoningContent)
|
||||
mergeProviderReasoningMetadata(last, message)
|
||||
return true
|
||||
}
|
||||
|
||||
func canMergeProviderAssistantToolCalls(last Message, current Message) bool {
|
||||
if strings.TrimSpace(last.Role) != "assistant" || strings.TrimSpace(current.Role) != "assistant" {
|
||||
return false
|
||||
}
|
||||
if len(last.ToolCalls) == 0 || len(current.ToolCalls) == 0 {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(last.ToolCallID) != "" || strings.TrimSpace(last.Name) != "" {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(current.ToolCallID) != "" || strings.TrimSpace(current.Name) != "" {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(current.Content) != "" || len(current.ContentParts) > 0 {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func mergeProviderReasoning(left string, right string) string {
|
||||
left = strings.TrimSpace(left)
|
||||
right = strings.TrimSpace(right)
|
||||
switch {
|
||||
case left == "":
|
||||
return right
|
||||
case right == "", right == left:
|
||||
return left
|
||||
default:
|
||||
return left + "\n\n" + right
|
||||
}
|
||||
}
|
||||
|
||||
func mergeProviderReasoningSignature(left string, right string) string {
|
||||
left = strings.TrimSpace(left)
|
||||
right = strings.TrimSpace(right)
|
||||
switch {
|
||||
case left == "":
|
||||
return right
|
||||
case right == "", right == left:
|
||||
return left
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func mergeProviderReasoningSignatureSource(left string, right string) string {
|
||||
left = strings.TrimSpace(left)
|
||||
right = strings.TrimSpace(right)
|
||||
switch {
|
||||
case left == "":
|
||||
return right
|
||||
case right == "", right == left:
|
||||
return left
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func mergeProviderReasoningMetadata(last *Message, current Message) {
|
||||
if last == nil {
|
||||
return
|
||||
}
|
||||
leftSignature := strings.TrimSpace(last.ReasoningSignature)
|
||||
rightSignature := strings.TrimSpace(current.ReasoningSignature)
|
||||
mergedSignature := mergeProviderReasoningSignature(leftSignature, rightSignature)
|
||||
last.ReasoningSignature = mergedSignature
|
||||
if mergedSignature == "" {
|
||||
last.ReasoningSignatureSource = ""
|
||||
last.OpenAIResponsesReasoningID = ""
|
||||
last.OpenAIResponsesReasoningStatus = ""
|
||||
last.OpenAIResponsesReasoningSummary = nil
|
||||
return
|
||||
}
|
||||
if leftSignature == "" && rightSignature != "" {
|
||||
last.ReasoningSignatureSource = strings.TrimSpace(current.ReasoningSignatureSource)
|
||||
last.OpenAIResponsesReasoningID = current.OpenAIResponsesReasoningID
|
||||
last.OpenAIResponsesReasoningStatus = current.OpenAIResponsesReasoningStatus
|
||||
last.OpenAIResponsesReasoningSummary = append([]byte(nil), current.OpenAIResponsesReasoningSummary...)
|
||||
return
|
||||
}
|
||||
if leftSignature == rightSignature {
|
||||
last.ReasoningSignatureSource = mergeProviderReasoningSignatureSource(last.ReasoningSignatureSource, current.ReasoningSignatureSource)
|
||||
if strings.TrimSpace(last.OpenAIResponsesReasoningID) == "" {
|
||||
last.OpenAIResponsesReasoningID = current.OpenAIResponsesReasoningID
|
||||
}
|
||||
if strings.TrimSpace(last.OpenAIResponsesReasoningStatus) == "" {
|
||||
last.OpenAIResponsesReasoningStatus = current.OpenAIResponsesReasoningStatus
|
||||
}
|
||||
if len(last.OpenAIResponsesReasoningSummary) == 0 {
|
||||
last.OpenAIResponsesReasoningSummary = append([]byte(nil), current.OpenAIResponsesReasoningSummary...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func trimDanglingAssistantToolCalls(input []Message) []Message {
|
||||
if len(input) == 0 {
|
||||
return nil
|
||||
}
|
||||
trimmed := make([]Message, 0, len(input))
|
||||
for index := 0; index < len(input); index++ {
|
||||
message := cloneProviderMessage(input[index])
|
||||
if strings.TrimSpace(message.Role) != "assistant" || len(message.ToolCalls) == 0 {
|
||||
trimmed = append(trimmed, message)
|
||||
continue
|
||||
}
|
||||
|
||||
end := index + 1
|
||||
responded := make(map[string]struct{}, len(message.ToolCalls))
|
||||
for end < len(input) && strings.TrimSpace(input[end].Role) == "tool" {
|
||||
toolCallID := strings.TrimSpace(input[end].ToolCallID)
|
||||
if toolCallID != "" {
|
||||
responded[toolCallID] = struct{}{}
|
||||
}
|
||||
end++
|
||||
}
|
||||
|
||||
nextToolCalls := make([]ToolCallDescriptor, 0, len(message.ToolCalls))
|
||||
allowedToolCallIDs := make(map[string]struct{}, len(message.ToolCalls))
|
||||
for _, toolCall := range message.ToolCalls {
|
||||
toolCallID := strings.TrimSpace(toolCall.ID)
|
||||
if _, ok := responded[toolCallID]; !ok {
|
||||
continue
|
||||
}
|
||||
item := toolCall
|
||||
item.Index = len(nextToolCalls)
|
||||
nextToolCalls = append(nextToolCalls, item)
|
||||
allowedToolCallIDs[toolCallID] = struct{}{}
|
||||
}
|
||||
|
||||
if len(nextToolCalls) > 0 {
|
||||
message.ToolCalls = nextToolCalls
|
||||
trimmed = append(trimmed, message)
|
||||
for toolIndex := index + 1; toolIndex < end; toolIndex++ {
|
||||
toolMessage := cloneProviderMessage(input[toolIndex])
|
||||
if _, ok := allowedToolCallIDs[strings.TrimSpace(toolMessage.ToolCallID)]; !ok {
|
||||
continue
|
||||
}
|
||||
trimmed = append(trimmed, toolMessage)
|
||||
}
|
||||
} else if strings.TrimSpace(message.Content) != "" || len(message.ContentParts) > 0 || strings.TrimSpace(message.ReasoningContent) != "" {
|
||||
message.ToolCalls = nil
|
||||
trimmed = append(trimmed, message)
|
||||
}
|
||||
|
||||
index = end - 1
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func normalizeRuntimeThinkingEffort(raw string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "disabled", "low", "medium", "high", "xhigh", "max":
|
||||
return strings.ToLower(strings.TrimSpace(raw))
|
||||
case "disable", "off", "none", "false", "no", "0":
|
||||
return "disabled"
|
||||
case "very_high", "very-high", "veryhigh", "x-high", "extra_high", "extra-high", "extrahigh":
|
||||
return "xhigh"
|
||||
case "maximum":
|
||||
return "max"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func openAIReasoningEffortFromRuntime(runtimeThinkingEffort string) string {
|
||||
switch normalizeRuntimeThinkingEffort(runtimeThinkingEffort) {
|
||||
case "low", "medium", "high", "xhigh", "max":
|
||||
return normalizeRuntimeThinkingEffort(runtimeThinkingEffort)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package modeladapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultProviderStreamIdleTimeout = 4 * time.Minute
|
||||
minProviderStreamIdleTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
type providerStreamIdleWatchdog struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelCauseFunc
|
||||
timeout time.Duration
|
||||
timer *time.Timer
|
||||
|
||||
mu sync.Mutex
|
||||
body io.Closer
|
||||
stopped bool
|
||||
timedOut bool
|
||||
err error
|
||||
}
|
||||
|
||||
func newProviderStreamIdleWatchdog(parent context.Context, timeout time.Duration) (context.Context, *providerStreamIdleWatchdog) {
|
||||
if parent == nil {
|
||||
parent = context.Background()
|
||||
}
|
||||
timeout = normalizeProviderStreamIdleTimeoutDuration(timeout)
|
||||
ctx, cancel := context.WithCancelCause(parent)
|
||||
watchdog := &providerStreamIdleWatchdog{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
timeout: timeout,
|
||||
err: providerStreamIdleTimeoutError(timeout),
|
||||
}
|
||||
watchdog.timer = time.AfterFunc(watchdog.timeout, watchdog.expire)
|
||||
return ctx, watchdog
|
||||
}
|
||||
|
||||
func (watchdog *providerStreamIdleWatchdog) AttachBody(body io.Closer) {
|
||||
if watchdog == nil || body == nil {
|
||||
return
|
||||
}
|
||||
watchdog.mu.Lock()
|
||||
watchdog.body = body
|
||||
shouldClose := watchdog.timedOut || watchdog.stopped
|
||||
watchdog.mu.Unlock()
|
||||
if shouldClose {
|
||||
_ = body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (watchdog *providerStreamIdleWatchdog) MarkEffectiveContent() {
|
||||
if watchdog == nil {
|
||||
return
|
||||
}
|
||||
watchdog.mu.Lock()
|
||||
defer watchdog.mu.Unlock()
|
||||
if watchdog.stopped || watchdog.timedOut || watchdog.timer == nil {
|
||||
return
|
||||
}
|
||||
watchdog.timer.Reset(watchdog.timeout)
|
||||
}
|
||||
|
||||
func (watchdog *providerStreamIdleWatchdog) Stop() {
|
||||
if watchdog == nil {
|
||||
return
|
||||
}
|
||||
watchdog.mu.Lock()
|
||||
if watchdog.stopped {
|
||||
watchdog.mu.Unlock()
|
||||
return
|
||||
}
|
||||
watchdog.stopped = true
|
||||
watchdog.body = nil
|
||||
if watchdog.timer != nil {
|
||||
watchdog.timer.Stop()
|
||||
}
|
||||
watchdog.mu.Unlock()
|
||||
watchdog.cancel(nil)
|
||||
}
|
||||
|
||||
func (watchdog *providerStreamIdleWatchdog) Err() error {
|
||||
if watchdog == nil {
|
||||
return nil
|
||||
}
|
||||
watchdog.mu.Lock()
|
||||
defer watchdog.mu.Unlock()
|
||||
if watchdog.timedOut {
|
||||
return watchdog.err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (watchdog *providerStreamIdleWatchdog) expire() {
|
||||
watchdog.mu.Lock()
|
||||
if watchdog.stopped || watchdog.timedOut {
|
||||
watchdog.mu.Unlock()
|
||||
return
|
||||
}
|
||||
watchdog.timedOut = true
|
||||
body := watchdog.body
|
||||
err := watchdog.err
|
||||
watchdog.mu.Unlock()
|
||||
|
||||
watchdog.cancel(err)
|
||||
if body != nil {
|
||||
_ = body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeProviderStreamIdleTimeoutDuration(timeout time.Duration) time.Duration {
|
||||
if timeout <= 0 {
|
||||
return defaultProviderStreamIdleTimeout
|
||||
}
|
||||
if timeout < minProviderStreamIdleTimeout {
|
||||
return minProviderStreamIdleTimeout
|
||||
}
|
||||
return timeout
|
||||
}
|
||||
|
||||
func providerStreamIdleTimeoutError(timeout time.Duration) error {
|
||||
seconds := int(timeout / time.Second)
|
||||
if seconds > 0 && timeout == time.Duration(seconds)*time.Second {
|
||||
return fmt.Errorf("provider stream idle timeout after %ds without effective content", seconds)
|
||||
}
|
||||
return fmt.Errorf("provider stream idle timeout after %s without effective content", timeout)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package modeladapter
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
maxProviderToolCallIDLen = 64
|
||||
toolCallNamespaceHashLen = 12
|
||||
toolCallValueHashLen = 12
|
||||
)
|
||||
|
||||
// namespaceToolCallID 为 provider 原始 tool call id 增加 model-call 级别命名空间,
|
||||
// 避免像 functions.Shell:0 这类跨轮复用的 id 在客户端被误判为同一个 bubble。
|
||||
//
|
||||
// OpenAI 等 provider 对 tool_call_id 长度有限制,因此这里使用 model_call_id 的短哈希
|
||||
// 而不是完整 UUID,保证内部存储的 tool_call_id 既稳定又能安全回放给 provider。
|
||||
func namespaceToolCallID(modelCallID string, rawToolCallID string) string {
|
||||
raw := strings.TrimSpace(rawToolCallID)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(raw, "::") {
|
||||
return providerToolCallID(raw)
|
||||
}
|
||||
model := strings.TrimSpace(modelCallID)
|
||||
if model == "" {
|
||||
return providerToolCallID(raw)
|
||||
}
|
||||
return buildProviderSafeToolCallID(shortToolCallHash(model, toolCallNamespaceHashLen), raw)
|
||||
}
|
||||
|
||||
// providerToolCallID 把内部持久化的 tool_call_id 规整成 provider 可接受的安全长度。
|
||||
// 这样旧会话里已经落盘的 legacy "<modelCallID>::<rawID>" 也能继续回放。
|
||||
func providerToolCallID(toolCallID string) string {
|
||||
trimmed := strings.TrimSpace(toolCallID)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
namespace, raw, ok := splitLegacyToolCallID(trimmed)
|
||||
if ok {
|
||||
return buildProviderSafeToolCallID(shortToolCallHash(namespace, toolCallNamespaceHashLen), raw)
|
||||
}
|
||||
if len(trimmed) <= maxProviderToolCallIDLen {
|
||||
return trimmed
|
||||
}
|
||||
return buildProviderSafeToolCallID("", trimmed)
|
||||
}
|
||||
|
||||
type providerToolCallDescriptor struct {
|
||||
ID string `json:"id"`
|
||||
Index int `json:"index,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Function ToolCallFunctionShape `json:"function"`
|
||||
}
|
||||
|
||||
func normalizeToolCallDescriptors(toolCalls []ToolCallDescriptor) []providerToolCallDescriptor {
|
||||
if len(toolCalls) == 0 {
|
||||
return nil
|
||||
}
|
||||
normalized := make([]providerToolCallDescriptor, 0, len(toolCalls))
|
||||
for _, toolCall := range toolCalls {
|
||||
item := providerToolCallDescriptor{
|
||||
ID: providerToolCallID(toolCall.ID),
|
||||
Index: toolCall.Index,
|
||||
Type: toolCall.Type,
|
||||
Function: toolCall.Function,
|
||||
}
|
||||
normalized = append(normalized, item)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func buildProviderSafeToolCallID(namespace string, raw string) string {
|
||||
trimmedRaw := strings.TrimSpace(raw)
|
||||
if trimmedRaw == "" {
|
||||
return ""
|
||||
}
|
||||
if namespace == "" && len(trimmedRaw) <= maxProviderToolCallIDLen && !strings.Contains(trimmedRaw, "::") {
|
||||
return trimmedRaw
|
||||
}
|
||||
|
||||
prefix := "tc"
|
||||
if namespace != "" {
|
||||
prefix += "_" + namespace
|
||||
}
|
||||
candidate := prefix + "_" + trimmedRaw
|
||||
if len(candidate) <= maxProviderToolCallIDLen {
|
||||
return candidate
|
||||
}
|
||||
|
||||
rawHash := shortToolCallHash(trimmedRaw, toolCallValueHashLen)
|
||||
remaining := maxProviderToolCallIDLen - len(prefix) - len(rawHash) - 2
|
||||
if remaining <= 0 {
|
||||
return prefix + "_" + rawHash
|
||||
}
|
||||
suffix := trimmedRaw
|
||||
if len(suffix) > remaining {
|
||||
suffix = suffix[len(suffix)-remaining:]
|
||||
}
|
||||
return prefix + "_" + rawHash + "_" + suffix
|
||||
}
|
||||
|
||||
func splitLegacyToolCallID(value string) (namespace string, raw string, ok bool) {
|
||||
namespace, raw, ok = strings.Cut(strings.TrimSpace(value), "::")
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
namespace = strings.TrimSpace(namespace)
|
||||
raw = strings.TrimSpace(raw)
|
||||
if namespace == "" || raw == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return namespace, raw, true
|
||||
}
|
||||
|
||||
func shortToolCallHash(value string, size int) string {
|
||||
sum := sha256.Sum256([]byte(strings.TrimSpace(value)))
|
||||
encoded := hex.EncodeToString(sum[:])
|
||||
if size <= 0 || size > len(encoded) {
|
||||
return encoded
|
||||
}
|
||||
return encoded[:size]
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// types.go 定义模型适配层的统一请求、事件与路由接口。
|
||||
package modeladapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"cursor/gen/agentv1"
|
||||
runtimecore "cursor/internal/backend/agent/core"
|
||||
)
|
||||
|
||||
const (
|
||||
// ReasoningSignatureSourceAnthropic 表示 signature 来自 Anthropic thinking signature。
|
||||
ReasoningSignatureSourceAnthropic = "anthropic"
|
||||
// ReasoningSignatureSourceOpenAIResponses 表示 signature 来自 OpenAI Responses encrypted reasoning content。
|
||||
ReasoningSignatureSourceOpenAIResponses = "openai_responses"
|
||||
)
|
||||
|
||||
// Message 表示模型适配层统一使用的消息结构。
|
||||
type Message struct {
|
||||
// Role 表示消息角色。
|
||||
Role string `json:"role"`
|
||||
// Content 表示消息文本内容。
|
||||
Content string `json:"content"`
|
||||
// ContentParts 表示消息中的结构化内容块,例如文本或图片。
|
||||
ContentParts []ContentPart `json:"content_parts,omitempty"`
|
||||
// ReasoningContent 表示推理内容(用于支持 reasoning 的模型)。
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
// ReasoningSignature 表示 provider 对推理内容签发的签名(如 Anthropic thinking signature)。
|
||||
ReasoningSignature string `json:"reasoning_signature,omitempty"`
|
||||
// ReasoningSignatureSource 表示 reasoning signature 的 provider 语义来源。
|
||||
ReasoningSignatureSource string `json:"reasoning_signature_source,omitempty"`
|
||||
// OpenAIResponsesReasoningID 保存 Responses reasoning output item 的原始 id。
|
||||
OpenAIResponsesReasoningID string `json:"openai_responses_reasoning_id,omitempty"`
|
||||
// OpenAIResponsesReasoningStatus 保存 Responses reasoning output item 的原始 status。
|
||||
OpenAIResponsesReasoningStatus string `json:"openai_responses_reasoning_status,omitempty"`
|
||||
// OpenAIResponsesReasoningSummary 保存 Responses reasoning output item 的原始 summary。
|
||||
OpenAIResponsesReasoningSummary json.RawMessage `json:"openai_responses_reasoning_summary,omitempty"`
|
||||
// ToolCalls 表示 assistant 发起的函数调用。
|
||||
ToolCalls []ToolCallDescriptor `json:"tool_calls,omitempty"`
|
||||
// ToolCallID 表示 tool role 关联的调用 id。
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
// Name 表示 tool role 的工具名。
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
type ToolCallDescriptor struct {
|
||||
ID string `json:"id"`
|
||||
Index int `json:"index,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Function ToolCallFunctionShape `json:"function"`
|
||||
OpenAIResponsesID string `json:"openai_responses_id,omitempty"`
|
||||
OpenAIResponsesCallID string `json:"openai_responses_call_id,omitempty"`
|
||||
OpenAIResponsesStatus string `json:"openai_responses_status,omitempty"`
|
||||
}
|
||||
|
||||
type ToolCallFunctionShape struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
}
|
||||
|
||||
// StreamRequest 表示一次统一的模型流请求。
|
||||
type StreamRequest struct {
|
||||
// RequestID 表示当前模型调用所属 request。
|
||||
RequestID string
|
||||
// RunID 表示当前模型调用所属 run。
|
||||
RunID string
|
||||
// ModelCallID 表示当前模型调用标识。
|
||||
ModelCallID string
|
||||
// ConversationID 表示当前模型调用所属会话,用于稳定 provider 侧 prompt cache 路由。
|
||||
ConversationID string
|
||||
// Mode 表示当前运行模式。
|
||||
Mode agentv1.AgentMode
|
||||
// ModelID 表示当前模型标识。
|
||||
ModelID string
|
||||
// ThinkingEffort 表示客户端在本轮运行时选择的思考强度覆盖。
|
||||
ThinkingEffort string
|
||||
// Provider 表示目标 provider 类型,例如 openai 或 anthropic。
|
||||
Provider string
|
||||
// BaseURL 表示请求应发送到的 provider 基础地址。
|
||||
BaseURL string
|
||||
// APIKey 表示 provider 鉴权凭据。
|
||||
APIKey string
|
||||
// ProviderModelID 表示 provider 侧真实模型标识。
|
||||
ProviderModelID string
|
||||
// ResolvedChannelID 表示本次请求实际命中的 adapter 渠道 ID。
|
||||
ResolvedChannelID string
|
||||
// ResolvedChannelName 表示本次请求实际命中的 adapter 展示名。
|
||||
ResolvedChannelName string
|
||||
// ResolvedContextWindowTokens 表示本次请求实际命中的 adapter 上下文窗口。
|
||||
ResolvedContextWindowTokens int
|
||||
// ReasoningEffort 表示 OpenAI 兼容 provider 的推理强度。
|
||||
ReasoningEffort string
|
||||
// OpenAIEndpoint 表示 OpenAI 兼容 provider 使用的 API 端点。
|
||||
OpenAIEndpoint string
|
||||
// OpenAIExtraParamsEnabled 表示是否启用 OpenAI 额外请求参数。
|
||||
OpenAIExtraParamsEnabled bool
|
||||
// OpenAIExtraParamsJSON 表示 OpenAI 额外请求参数 JSON 对象。
|
||||
OpenAIExtraParamsJSON string
|
||||
// CustomHeadersEnabled 表示是否启用自定义请求头。
|
||||
CustomHeadersEnabled bool
|
||||
// CustomHeadersJSON 表示自定义请求头 JSON 对象。
|
||||
CustomHeadersJSON string
|
||||
// AnthropicExtraParamsEnabled 表示是否启用 Anthropic 额外请求参数。
|
||||
AnthropicExtraParamsEnabled bool
|
||||
// AnthropicExtraParamsJSON 表示 Anthropic 额外请求参数 JSON 对象。
|
||||
AnthropicExtraParamsJSON string
|
||||
// AnthropicMaxTokens 表示 Anthropic 兼容 provider 的 max_tokens。
|
||||
AnthropicMaxTokens int
|
||||
// AnthropicThinkingEffort 表示 Anthropic adaptive thinking 的 output_config.effort。
|
||||
AnthropicThinkingEffort string
|
||||
// ThinkingBudgetTokens 表示 Anthropic thinking 预算。
|
||||
ThinkingBudgetTokens int
|
||||
// Messages 表示按顺序排列的消息列表。
|
||||
Messages []Message
|
||||
// StableMessageCount 表示 messages 中可作为稳定缓存前缀的 provider-visible 消息数量。
|
||||
StableMessageCount int
|
||||
// Tools 表示原始工具描述 JSON 列表。
|
||||
Tools []json.RawMessage
|
||||
// MaxTokens 表示本轮最大输出 token 数。
|
||||
MaxTokens int
|
||||
// Stream 表示当前请求必须使用流式。
|
||||
Stream bool
|
||||
// RequestKnobs 保存本轮请求的附加参数摘要。
|
||||
RequestKnobs map[string]any
|
||||
// CompileSummary 保存当前 prompt 编译摘要。
|
||||
CompileSummary string
|
||||
// Observer 负责写入 request-scoped LLM 工件。
|
||||
Observer LLMArtifactObserver
|
||||
// ArtifactPaths 用于由 adapter 回填工件路径。
|
||||
ArtifactPaths *LLMArtifactPaths
|
||||
// RequestBodyOverride 表示直接复用的 provider 原始请求体;设置后由 adapter 原样发送。
|
||||
RequestBodyOverride map[string]any
|
||||
// ProviderStreamIdleTimeout 表示 provider 流式响应无有效内容时的空闲超时。
|
||||
ProviderStreamIdleTimeout time.Duration
|
||||
}
|
||||
|
||||
// LLMArtifactPaths 表示一次模型调用相关工件路径。
|
||||
type LLMArtifactPaths struct {
|
||||
RequestPath string
|
||||
ResponsePath string
|
||||
SummaryPath string
|
||||
}
|
||||
|
||||
// LLMArtifactObserver 定义模型调用原始工件写入接口。
|
||||
type LLMArtifactObserver interface {
|
||||
RecordLLMRequest(requestID string, runID string, modelCallID string, payload map[string]any) (string, error)
|
||||
AppendLLMResponseChunk(requestID string, runID string, modelCallID string, chunk string) (string, error)
|
||||
RecordLLMSummary(requestID string, runID string, modelCallID string, payload map[string]any) (string, error)
|
||||
}
|
||||
|
||||
// ModelEventKind 表示统一模型事件类型。
|
||||
type ModelEventKind string
|
||||
|
||||
const (
|
||||
// ModelEventKindTextDelta 表示文本增量事件。
|
||||
ModelEventKindTextDelta ModelEventKind = "text_delta"
|
||||
// ModelEventKindThinkingDelta 表示思考增量事件。
|
||||
ModelEventKindThinkingDelta ModelEventKind = "thinking_delta"
|
||||
// ModelEventKindThinkingCompleted 表示思考结束事件。
|
||||
ModelEventKindThinkingCompleted ModelEventKind = "thinking_completed"
|
||||
// ModelEventKindPartialToolCall 表示工具调用已开始,但参数仍在流式生成中。
|
||||
ModelEventKindPartialToolCall ModelEventKind = "partial_tool_call"
|
||||
// ModelEventKindToolCallDelta 表示工具调用参数或输出的流式增量。
|
||||
ModelEventKindToolCallDelta ModelEventKind = "tool_call_delta"
|
||||
// ModelEventKindToolLikeCompleted 表示工具意图已完整收口。
|
||||
ModelEventKindToolLikeCompleted ModelEventKind = "tool_like_completed"
|
||||
// ModelEventKindTurnFinished 表示当前模型回合结束。
|
||||
ModelEventKindTurnFinished ModelEventKind = "turn_finished"
|
||||
// ModelEventKindProviderError 表示 provider 侧返回错误。
|
||||
ModelEventKindProviderError ModelEventKind = "provider_error"
|
||||
)
|
||||
|
||||
// ModelEvent 表示一条统一模型事件。
|
||||
type ModelEvent struct {
|
||||
// Kind 表示事件类型。
|
||||
Kind ModelEventKind
|
||||
// OccurredAt 表示当前 provider 事件发生时间。
|
||||
OccurredAt time.Time
|
||||
// Provider 表示当前事件所属 provider。
|
||||
Provider string
|
||||
// Model 表示当前事件所属模型标识。
|
||||
Model string
|
||||
// Text 表示文本增量。
|
||||
Text string
|
||||
// ThinkingStyle 表示思考样式。
|
||||
ThinkingStyle agentv1.ThinkingStyle
|
||||
// ThinkingDurationMS 表示思考持续时长。
|
||||
ThinkingDurationMS int32
|
||||
// ThinkingSignature 表示 provider 返回的思考签名(如 Anthropic signature_delta)。
|
||||
ThinkingSignature string
|
||||
// ThinkingSignatureSource 表示思考签名的 provider 语义来源。
|
||||
ThinkingSignatureSource string
|
||||
// ProviderItemID 保存 provider 原始 output item id,用于 stateless Responses replay。
|
||||
ProviderItemID string
|
||||
// ProviderStatus 保存 provider 原始 output item status,用于 stateless Responses replay。
|
||||
ProviderStatus string
|
||||
// ProviderSummary 保存 provider 原始 output item summary,用于 stateless Responses replay。
|
||||
ProviderSummary json.RawMessage
|
||||
// ProviderCallID 保存 provider 原始 tool/function call id,用于 stateless Responses replay。
|
||||
ProviderCallID string
|
||||
// ToolCallID 表示当前 partial/delta 对应的工具调用标识。
|
||||
ToolCallID string
|
||||
// ToolCall 保存 partial tool call 当前可公开的结构化快照。
|
||||
ToolCall *agentv1.ToolCall
|
||||
// ToolCallDelta 保存与当前工具调用相关的流式增量。
|
||||
ToolCallDelta *agentv1.ToolCallDelta
|
||||
// ArgsTextDelta 保存原始工具参数文本增量,供兼容层透传。
|
||||
ArgsTextDelta string
|
||||
// InputTokens 表示当前已知的输入 token 数。
|
||||
InputTokens int64
|
||||
// OutputTokens 表示当前已知的输出 token 数。
|
||||
OutputTokens int64
|
||||
// CacheReadTokens 表示当前已知的 cache read token 数。
|
||||
CacheReadTokens int64
|
||||
// CacheWriteTokens 表示当前已知的 cache write token 数。
|
||||
CacheWriteTokens int64
|
||||
// UsagePresent 表示 provider 本次流里实际返回过 usage 对象。
|
||||
UsagePresent bool
|
||||
// CacheReadPresent 表示 provider 明确返回了 cache read token 字段。
|
||||
CacheReadPresent bool
|
||||
// CacheWritePresent 表示 provider 明确返回了 cache write token 字段。
|
||||
CacheWritePresent bool
|
||||
// ToolInvocation 表示完成收口的工具调用意图。
|
||||
ToolInvocation *runtimecore.ToolInvocation
|
||||
// FinishReason 表示回合结束原因。
|
||||
FinishReason string
|
||||
// Err 表示 provider 错误。
|
||||
Err error
|
||||
}
|
||||
|
||||
// ModelAdapter 定义具体 provider 适配器接口。
|
||||
type ModelAdapter interface {
|
||||
// Stream 按流式方式发送请求,并持续产出统一模型事件。
|
||||
Stream(ctx context.Context, req StreamRequest, sink func(ModelEvent) error) error
|
||||
}
|
||||
|
||||
// ModelAdapterRouter 定义 provider 路由接口。
|
||||
type ModelAdapterRouter interface {
|
||||
// Stream 根据模型标识选择底层 provider 适配器。
|
||||
Stream(ctx context.Context, req StreamRequest, sink func(ModelEvent) error) error
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package modeladapter
|
||||
|
||||
const (
|
||||
// ClaudeCodeUserAgent 用于将渠道模型请求伪装为 Claude Code 客户端。
|
||||
ClaudeCodeUserAgent = "claude-cli/2.1.19 (external, sdk-cli)"
|
||||
// AnthropicClaudeCodeUserAgent 用于 Anthropic provider 的 Claude Code UA 兼容。
|
||||
AnthropicClaudeCodeUserAgent = "claude-cli/1.0.25"
|
||||
)
|
||||
Reference in New Issue
Block a user