This commit is contained in:
leokun
2026-06-30 10:38:52 +08:00
commit c083be5ec2
312 changed files with 146628 additions and 0 deletions
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)
}
+205
View File
@@ -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
}
+2
View File
@@ -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
}
+49
View File
@@ -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 ""
}
+419
View File
@@ -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 ""
}
}
+133
View File
@@ -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]
}
+243
View File
@@ -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"
)