声明式解析把 Ark/Seedream 的裸 b64_json 写成 DataURL 时未加 data: 前缀,导致图片任务在上游成功后仍报媒体结果下载失败。 Co-authored-by: Cursor <cursoragent@cursor.com>
1448 lines
49 KiB
Go
1448 lines
49 KiB
Go
package protocol
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"net/url"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
)
|
||
|
||
type ManifestOperation struct {
|
||
Method string `json:"method"`
|
||
Path string `json:"path"`
|
||
PathTemplate any `json:"pathTemplate,omitempty"`
|
||
OriginPath bool `json:"originPath,omitempty"`
|
||
ContentType string `json:"contentType,omitempty"`
|
||
ContentTypeTemplate any `json:"contentTypeTemplate,omitempty"`
|
||
Headers map[string]any `json:"headers,omitempty"`
|
||
Query map[string]any `json:"query,omitempty"`
|
||
Body any `json:"body,omitempty"`
|
||
Fields map[string]string `json:"fields,omitempty"`
|
||
Files []ManifestFilePart `json:"files,omitempty"`
|
||
}
|
||
|
||
type ManifestFilePart struct {
|
||
Name string `json:"name"`
|
||
Source any `json:"source"`
|
||
Filename any `json:"filename,omitempty"`
|
||
MIMEType any `json:"mimeType,omitempty"`
|
||
}
|
||
|
||
type ManifestResponse struct {
|
||
TaskIDPaths []string `json:"taskIdPaths,omitempty"`
|
||
StatusPaths []string `json:"statusPaths,omitempty"`
|
||
ErrorPaths []string `json:"errorPaths,omitempty"`
|
||
MessagePaths []string `json:"messagePaths,omitempty"`
|
||
TextPaths []string `json:"textPaths,omitempty"`
|
||
ReasoningPaths []string `json:"reasoningPaths,omitempty"`
|
||
ResultURLPaths []string `json:"resultUrlPaths,omitempty"`
|
||
ResultPaths []string `json:"resultPaths,omitempty"`
|
||
ResultKind string `json:"resultKind,omitempty"`
|
||
ResultEphemeral bool `json:"resultEphemeral,omitempty"`
|
||
// BinaryPayload 表示 create 同步返回二进制媒体(如 /audio/speech 的音频流)。
|
||
// 声明式解析会把整个响应体包装为对应能力的单个媒体结果,不做 JSON 路径提取。
|
||
BinaryPayload bool `json:"binaryPayload,omitempty"`
|
||
TaskID any `json:"taskId,omitempty"`
|
||
Status any `json:"status,omitempty"`
|
||
Message any `json:"message,omitempty"`
|
||
Text any `json:"text,omitempty"`
|
||
Reasoning any `json:"reasoning,omitempty"`
|
||
Images any `json:"images,omitempty"`
|
||
Videos any `json:"videos,omitempty"`
|
||
Audios any `json:"audios,omitempty"`
|
||
Usage any `json:"usage,omitempty"`
|
||
}
|
||
|
||
// ManifestAgentResponse describes the provider response shape for a
|
||
// tool-capable text request. Tool call paths are resolved against each item in
|
||
// ToolCallsPath.
|
||
type ManifestAgentResponse struct {
|
||
TextPaths []string `json:"textPaths,omitempty"`
|
||
ReasoningPaths []string `json:"reasoningPaths,omitempty"`
|
||
ToolCallsPath string `json:"toolCallsPath,omitempty"`
|
||
ToolCallIDPaths []string `json:"toolCallIdPaths,omitempty"`
|
||
ToolCallNamePaths []string `json:"toolCallNamePaths,omitempty"`
|
||
ToolCallArgsPaths []string `json:"toolCallArgumentsPaths,omitempty"`
|
||
ToolCallSignaturePaths []string `json:"toolCallThoughtSignaturePaths,omitempty"`
|
||
}
|
||
|
||
// AdapterResolver is used by the host to bind a shipped execution engine to a
|
||
// manifest. Uploaded plugins must use the declarative path; host bindings are
|
||
// reserved for manifests shipped with the application.
|
||
type AdapterResolver func(string) (Adapter, bool)
|
||
|
||
func LoadInstalledPlugin(data []byte, resolve AdapterResolver) (Adapter, error) {
|
||
adapters, err := LoadInstalledProviders(data, resolve)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(adapters) == 0 {
|
||
return nil, fmt.Errorf("plugin does not provide an executable provider")
|
||
}
|
||
return adapters[0], nil
|
||
}
|
||
|
||
// LoadInstalledProviders loads every provider contribution from one plugin
|
||
// package. The package remains the lifecycle and permission unit; providers
|
||
// are separate execution entries in the runtime index.
|
||
func LoadInstalledProviders(data []byte, resolve AdapterResolver) ([]Adapter, error) {
|
||
manifest, err := decodeManifest(data)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if strings.HasPrefix(strings.TrimSpace(manifest.Runtime.Backend), "host:") {
|
||
if len(manifest.Contributes.Providers) != 1 {
|
||
return nil, fmt.Errorf("host-backed plugin must declare exactly one provider")
|
||
}
|
||
if resolve == nil {
|
||
return nil, fmt.Errorf("plugin %q requires a host execution engine", manifest.Metadata.ID)
|
||
}
|
||
name := strings.TrimPrefix(strings.TrimSpace(manifest.Runtime.Backend), "host:")
|
||
adapter, ok := resolve(name)
|
||
if !ok {
|
||
return nil, fmt.Errorf("plugin host execution %q is unavailable", name)
|
||
}
|
||
if err := ValidateManifest(manifest); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := normalizeManifest(&manifest); err != nil {
|
||
return nil, err
|
||
}
|
||
return []Adapter{metadataAdapter{metadata: manifest.Metadata, delegate: adapter}}, nil
|
||
}
|
||
if len(manifest.Contributes.Providers) == 0 {
|
||
adapter, err := loadDeclarativeManifest(manifest)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return []Adapter{adapter}, nil
|
||
}
|
||
result := make([]Adapter, 0, len(manifest.Contributes.Providers))
|
||
for index := range manifest.Contributes.Providers {
|
||
adapter, err := loadDeclarativeManifestProvider(manifest, index)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
result = append(result, adapter)
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func validatePluginMetadata(metadata Metadata) error {
|
||
if strings.TrimSpace(metadata.ID) == "" || strings.TrimSpace(metadata.Version) == "" || !validManifestIdentifier(metadata.ID) {
|
||
return fmt.Errorf("protocol plugin metadata is invalid")
|
||
}
|
||
if len(metadata.Categories) == 0 || len(metadata.Scopes) == 0 {
|
||
return fmt.Errorf("protocol plugin metadata requires categories and scopes")
|
||
}
|
||
for _, capability := range metadata.Categories {
|
||
if capability != CapabilityText && capability != CapabilityImage && capability != CapabilityVideo && capability != CapabilityAudio {
|
||
return fmt.Errorf("unsupported protocol capability %q", capability)
|
||
}
|
||
}
|
||
for _, scope := range metadata.Scopes {
|
||
switch scope {
|
||
case SurfaceAdminSystemChannel, SurfaceUserCustomChannel, SurfaceCanvas, SurfaceCreation, SurfaceAgent:
|
||
default:
|
||
return fmt.Errorf("unsupported protocol scope %q", scope)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
type metadataAdapter struct {
|
||
metadata Metadata
|
||
delegate Adapter
|
||
}
|
||
|
||
func (a metadataAdapter) Metadata() Metadata { return a.metadata }
|
||
func (a metadataAdapter) AgentAvailable() bool {
|
||
capability, ok := a.delegate.(AgentCapability)
|
||
return ok && capability.AgentAvailable()
|
||
}
|
||
func (a metadataAdapter) ResultAvailable() bool {
|
||
capability, ok := a.delegate.(ResultCapability)
|
||
return ok && capability.ResultAvailable()
|
||
}
|
||
func (a metadataAdapter) BuildCreate(ctx context.Context, c RequestContext) (RequestSpec, error) {
|
||
return a.delegate.BuildCreate(ctx, c)
|
||
}
|
||
func (a metadataAdapter) ParseCreate(ctx context.Context, body []byte) (CreateResult, error) {
|
||
return a.delegate.ParseCreate(ctx, body)
|
||
}
|
||
func (a metadataAdapter) BuildPoll(ctx context.Context, c PollContext) (RequestSpec, error) {
|
||
return a.delegate.BuildPoll(ctx, c)
|
||
}
|
||
func (a metadataAdapter) ParsePoll(ctx context.Context, c PollContext, body []byte) (PollResult, error) {
|
||
return a.delegate.ParsePoll(ctx, c, body)
|
||
}
|
||
func (a metadataAdapter) BuildCancel(ctx context.Context, c PollContext) (RequestSpec, error) {
|
||
return a.delegate.BuildCancel(ctx, c)
|
||
}
|
||
func (a metadataAdapter) BuildResult(ctx context.Context, c PollContext) (RequestSpec, error) {
|
||
adapter, ok := a.delegate.(ResultAdapter)
|
||
if !ok {
|
||
return RequestSpec{}, unavailable(a.metadata)
|
||
}
|
||
return adapter.BuildResult(ctx, c)
|
||
}
|
||
func (a metadataAdapter) BuildAgent(ctx context.Context, c AgentRequestContext) (RequestSpec, error) {
|
||
adapter, ok := a.delegate.(AgentAdapter)
|
||
if !ok {
|
||
return RequestSpec{}, unavailable(a.metadata)
|
||
}
|
||
return adapter.BuildAgent(ctx, c)
|
||
}
|
||
func (a metadataAdapter) ParseAgent(ctx context.Context, body []byte) (AgentResult, error) {
|
||
adapter, ok := a.delegate.(AgentAdapter)
|
||
if !ok {
|
||
return AgentResult{}, unavailable(a.metadata)
|
||
}
|
||
return adapter.ParseAgent(ctx, body)
|
||
}
|
||
|
||
func LoadManifest(data []byte) (Adapter, error) {
|
||
manifest, err := decodeManifest(data)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return loadDeclarativeManifest(manifest)
|
||
}
|
||
|
||
func decodeManifest(data []byte) (Manifest, error) {
|
||
var manifest Manifest
|
||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||
return Manifest{}, fmt.Errorf("decode plugin manifest: %w", err)
|
||
}
|
||
if err := ValidateManifest(manifest); err != nil {
|
||
return Manifest{}, err
|
||
}
|
||
return manifest, nil
|
||
}
|
||
|
||
func loadDeclarativeManifest(manifest Manifest) (Adapter, error) {
|
||
manifest.Runtime.Backend = "declarative"
|
||
if err := normalizeManifest(&manifest); err != nil {
|
||
return nil, err
|
||
}
|
||
return manifestAdapter{manifest: manifest}, nil
|
||
}
|
||
|
||
func loadDeclarativeManifestProvider(manifest Manifest, index int) (Adapter, error) {
|
||
manifest.Runtime.Backend = "declarative"
|
||
if err := normalizeManifestForProvider(&manifest, index); err != nil {
|
||
return nil, err
|
||
}
|
||
return manifestAdapter{manifest: manifest}, nil
|
||
}
|
||
|
||
func ValidateManifest(manifest Manifest) error {
|
||
if version := strings.TrimSpace(manifest.APIVersion); version != "yingce.plugin/v1" && version != "yingce.plugin/v2" {
|
||
return fmt.Errorf("unsupported protocol manifest apiVersion %q", manifest.APIVersion)
|
||
}
|
||
if strings.TrimSpace(manifest.Metadata.ID) == "" || strings.TrimSpace(manifest.Metadata.Version) == "" {
|
||
return fmt.Errorf("protocol manifest metadata requires id and version")
|
||
}
|
||
if strings.TrimSpace(manifest.Metadata.Name) == "" {
|
||
return fmt.Errorf("protocol manifest metadata requires name")
|
||
}
|
||
if !validManifestIdentifier(manifest.Metadata.ID) {
|
||
return fmt.Errorf("protocol manifest metadata id is invalid")
|
||
}
|
||
if len(manifest.Metadata.Name) > 160 || len(manifest.Metadata.Vendor) > 120 {
|
||
return fmt.Errorf("protocol manifest metadata name or vendor is too long")
|
||
}
|
||
if len(manifest.Contributes.Providers) == 0 && !hasNonProviderContribution(manifest.Contributes) {
|
||
return fmt.Errorf("plugin must declare at least one contribution")
|
||
}
|
||
if backend := strings.TrimSpace(manifest.Runtime.Backend); backend != "" && backend != "declarative" && backend != "rpc" && backend != "wasm" && backend != "trusted-backend" && !strings.HasPrefix(backend, "host:") {
|
||
return fmt.Errorf("unsupported plugin backend %q", backend)
|
||
}
|
||
if len(manifest.Contributes.Providers) == 0 {
|
||
return validatePaymentProviderContributions(manifest)
|
||
}
|
||
providerIDs := make(map[string]struct{}, len(manifest.Contributes.Providers))
|
||
for index, provider := range manifest.Contributes.Providers {
|
||
if strings.TrimSpace(provider.ID) == "" || strings.TrimSpace(provider.Label) == "" || !validManifestIdentifier(provider.ID) {
|
||
return fmt.Errorf("provider contribution %d requires a valid id and label", index)
|
||
}
|
||
if _, exists := providerIDs[provider.ID]; exists {
|
||
return fmt.Errorf("duplicate provider contribution %q", provider.ID)
|
||
}
|
||
providerIDs[provider.ID] = struct{}{}
|
||
if len(provider.Capabilities) == 0 || len(provider.Scopes) == 0 {
|
||
return fmt.Errorf("provider contribution %q requires capabilities and scopes", provider.ID)
|
||
}
|
||
for _, capability := range provider.Capabilities {
|
||
if capability != CapabilityText && capability != CapabilityImage && capability != CapabilityVideo && capability != CapabilityAudio {
|
||
return fmt.Errorf("unsupported protocol capability %q", capability)
|
||
}
|
||
}
|
||
for _, scope := range provider.Scopes {
|
||
switch scope {
|
||
case SurfaceAdminSystemChannel, SurfaceUserCustomChannel, SurfaceCanvas, SurfaceCreation, SurfaceAgent:
|
||
default:
|
||
return fmt.Errorf("unsupported protocol scope %q", scope)
|
||
}
|
||
}
|
||
if err := validateManifestOperation(provider.Create); err != nil {
|
||
return fmt.Errorf("provider %q create operation: %w", provider.ID, err)
|
||
}
|
||
if provider.Agent != nil {
|
||
if err := validateManifestOperation(*provider.Agent); err != nil {
|
||
return fmt.Errorf("provider %q agent operation: %w", provider.ID, err)
|
||
}
|
||
if provider.AgentResponse == nil {
|
||
return fmt.Errorf("provider %q agent response mapping is required when agent operation is declared", provider.ID)
|
||
}
|
||
}
|
||
if provider.Poll != nil {
|
||
if err := validateManifestOperation(*provider.Poll); err != nil {
|
||
return fmt.Errorf("provider %q poll operation: %w", provider.ID, err)
|
||
}
|
||
}
|
||
if provider.Cancel != nil {
|
||
if err := validateManifestOperation(*provider.Cancel); err != nil {
|
||
return fmt.Errorf("provider %q cancel operation: %w", provider.ID, err)
|
||
}
|
||
}
|
||
if provider.Result != nil {
|
||
if err := validateManifestOperation(*provider.Result); err != nil {
|
||
return fmt.Errorf("provider %q result operation: %w", provider.ID, err)
|
||
}
|
||
}
|
||
for ruleIndex, rule := range provider.Validations {
|
||
if rule.Assert == nil || strings.TrimSpace(rule.Message) == "" {
|
||
return fmt.Errorf("provider %q validation %d requires assert and message", provider.ID, ruleIndex)
|
||
}
|
||
}
|
||
}
|
||
return validatePaymentProviderContributions(manifest)
|
||
}
|
||
|
||
func validatePaymentProviderContributions(manifest Manifest) error {
|
||
seen := make(map[string]struct{}, len(manifest.Contributes.PaymentProviders))
|
||
for index, provider := range manifest.Contributes.PaymentProviders {
|
||
provider.ID = strings.TrimSpace(provider.ID)
|
||
if provider.ID == "" || strings.TrimSpace(provider.Label) == "" || !validManifestIdentifier(provider.ID) {
|
||
return fmt.Errorf("payment provider contribution %d requires a valid id and label", index)
|
||
}
|
||
if _, exists := seen[provider.ID]; exists {
|
||
return fmt.Errorf("duplicate payment provider contribution %q", provider.ID)
|
||
}
|
||
seen[provider.ID] = struct{}{}
|
||
if strings.TrimSpace(provider.Icon) == "" {
|
||
return fmt.Errorf("payment provider contribution %q requires an icon", provider.ID)
|
||
}
|
||
switch provider.CheckoutMode {
|
||
case "qr_code", "redirect":
|
||
default:
|
||
return fmt.Errorf("payment provider contribution %q has unsupported checkout mode %q", provider.ID, provider.CheckoutMode)
|
||
}
|
||
policy := provider.ExpiryPolicy
|
||
if policy.MinMinutes <= 0 || policy.DefaultMinutes < policy.MinMinutes || policy.MaxMinutes < policy.DefaultMinutes {
|
||
return fmt.Errorf("payment provider contribution %q has invalid expiry policy", provider.ID)
|
||
}
|
||
for _, field := range provider.IdentityFields {
|
||
if strings.TrimSpace(field) == "" || len(field) > 80 {
|
||
return fmt.Errorf("payment provider contribution %q has invalid identity field %q", provider.ID, field)
|
||
}
|
||
}
|
||
for _, response := range []ManifestPaymentResponse{provider.NotificationSuccess, provider.NotificationFailure} {
|
||
if response.Status < 0 || response.Status > 599 {
|
||
return fmt.Errorf("payment provider contribution %q has invalid notification response status", provider.ID)
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func normalizeManifest(manifest *Manifest) error {
|
||
if manifest == nil {
|
||
return fmt.Errorf("plugin manifest is missing")
|
||
}
|
||
if len(manifest.Contributes.Providers) == 0 {
|
||
manifest.Metadata.Execution = manifest.Runtime.Backend
|
||
return nil
|
||
}
|
||
return normalizeManifestForProvider(manifest, 0)
|
||
}
|
||
|
||
func normalizeManifestForProvider(manifest *Manifest, index int) error {
|
||
if manifest == nil || index < 0 || index >= len(manifest.Contributes.Providers) {
|
||
return fmt.Errorf("plugin provider contribution is missing")
|
||
}
|
||
provider := manifest.Contributes.Providers[index]
|
||
manifest.Metadata.ID = provider.ID
|
||
manifest.Metadata.Categories = provider.Capabilities
|
||
manifest.Metadata.Scopes = provider.Scopes
|
||
manifest.Metadata.Parameters = provider.Parameters
|
||
manifest.Metadata.Create = operationSummary(provider.Create)
|
||
manifest.Metadata.Poll = operationSummaryPtr(provider.Poll)
|
||
manifest.Metadata.Cancel = operationSummaryPtr(provider.Cancel)
|
||
manifest.Metadata.ContentType = provider.Create.ContentType
|
||
manifest.Metadata.RequiresPublicMediaURLs = provider.RequiresPublicMediaURLs
|
||
manifest.Metadata.Execution = manifest.Runtime.Backend
|
||
manifest.Create = provider.Create
|
||
manifest.Agent = provider.Agent
|
||
manifest.Poll = provider.Poll
|
||
manifest.Cancel = provider.Cancel
|
||
manifest.ResultOperation = provider.Result
|
||
manifest.Response = provider.Response
|
||
manifest.AgentResponse = provider.AgentResponse
|
||
manifest.Auth = provider.Auth
|
||
manifest.Validations = provider.Validations
|
||
return nil
|
||
}
|
||
|
||
func hasNonProviderContribution(contributes ManifestContributions) bool {
|
||
return len(contributes.PaymentProviders) > 0 || len(contributes.Workflows) > 0 || len(contributes.CanvasNodes) > 0 || len(contributes.Transforms) > 0 || len(contributes.Commands) > 0 || len(contributes.AssetSources) > 0 || len(contributes.UsageObservers) > 0 || len(contributes.AICapabilities) > 0 || len(contributes.Agents) > 0 || len(contributes.ImportExport) > 0
|
||
}
|
||
|
||
func operationSummary(operation ManifestOperation) string {
|
||
path := strings.ReplaceAll(operation.Path, "{{model}}", "{model}")
|
||
path = strings.ReplaceAll(path, "{{taskId}}", "{task_id}")
|
||
return strings.ToUpper(operation.Method) + " " + path
|
||
}
|
||
|
||
func operationSummaryPtr(operation *ManifestOperation) string {
|
||
if operation == nil {
|
||
return ""
|
||
}
|
||
return operationSummary(*operation)
|
||
}
|
||
|
||
func validateManifestOperation(operation ManifestOperation) error {
|
||
method := strings.ToUpper(strings.TrimSpace(operation.Method))
|
||
if method != http.MethodGet && method != http.MethodPost && method != http.MethodDelete && method != http.MethodPut {
|
||
return fmt.Errorf("unsupported HTTP method %q", operation.Method)
|
||
}
|
||
if operation.PathTemplate == nil && !isRelativePath(operation.Path) {
|
||
return fmt.Errorf("path must be relative: %q", operation.Path)
|
||
}
|
||
contentType := strings.ToLower(strings.TrimSpace(strings.Split(defaultValue(operation.ContentType, "application/json"), ";")[0]))
|
||
switch contentType {
|
||
case "application/json", "multipart/form-data", "application/x-www-form-urlencoded", "application/octet-stream", "":
|
||
default:
|
||
return fmt.Errorf("unsupported content type %q", operation.ContentType)
|
||
}
|
||
if len(operation.Files) > 0 && operation.ContentTypeTemplate == nil && contentType != "multipart/form-data" {
|
||
return fmt.Errorf("file parts require multipart/form-data")
|
||
}
|
||
for _, file := range operation.Files {
|
||
if strings.TrimSpace(file.Name) == "" || file.Source == nil {
|
||
return fmt.Errorf("multipart file part requires name and source")
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
type manifestAdapter struct{ manifest Manifest }
|
||
|
||
func (a manifestAdapter) Metadata() Metadata { return a.manifest.Metadata }
|
||
func (a manifestAdapter) AgentAvailable() bool {
|
||
return a.manifest.Agent != nil && a.manifest.AgentResponse != nil
|
||
}
|
||
func (a manifestAdapter) ResultAvailable() bool { return a.manifest.ResultOperation != nil }
|
||
func (a manifestAdapter) BuildCreate(_ context.Context, c RequestContext) (RequestSpec, error) {
|
||
if len(a.manifest.Contributes.Providers) == 0 {
|
||
return RequestSpec{}, fmt.Errorf("plugin %s does not provide a provider", a.manifest.Metadata.ID)
|
||
}
|
||
if err := validateManifestRequest(a.manifest.Validations, c.Request); err != nil {
|
||
return RequestSpec{}, err
|
||
}
|
||
return buildManifestOperation(a.manifest.Create, a.manifest.Auth, c.Request, "")
|
||
}
|
||
func (a manifestAdapter) BuildAgent(_ context.Context, c AgentRequestContext) (RequestSpec, error) {
|
||
if a.manifest.Agent == nil {
|
||
return RequestSpec{}, fmt.Errorf("protocol %s has no agent operation", a.manifest.Metadata.ID)
|
||
}
|
||
request := GenerationRequest{Model: c.Model, Extra: map[string]any{"agent": c.Request}}
|
||
return buildManifestOperation(*a.manifest.Agent, a.manifest.Auth, request, "")
|
||
}
|
||
func (a manifestAdapter) ParseAgent(_ context.Context, body []byte) (AgentResult, error) {
|
||
if a.manifest.AgentResponse == nil {
|
||
return AgentResult{}, fmt.Errorf("protocol %s has no agent response mapping", a.manifest.Metadata.ID)
|
||
}
|
||
payload, err := decodeObject(body)
|
||
if err != nil {
|
||
return AgentResult{}, err
|
||
}
|
||
response := a.manifest.AgentResponse
|
||
result := AgentResult{Text: firstPathValue(payload, response.TextPaths...), Reasoning: firstPathValue(payload, response.ReasoningPaths...)}
|
||
if response.ToolCallsPath == "" {
|
||
return result, nil
|
||
}
|
||
for index, item := range arrayValue(pathValue(payload, response.ToolCallsPath)) {
|
||
object, ok := item.(map[string]any)
|
||
if !ok {
|
||
continue
|
||
}
|
||
call := AgentToolCall{
|
||
ID: firstPathValue(object, response.ToolCallIDPaths...),
|
||
Name: firstPathValue(object, response.ToolCallNamePaths...),
|
||
Arguments: firstPathJSONValue(object, response.ToolCallArgsPaths...),
|
||
ThoughtSignature: firstPathValue(object, response.ToolCallSignaturePaths...),
|
||
}
|
||
if strings.TrimSpace(call.Name) != "" {
|
||
if strings.TrimSpace(call.ID) == "" {
|
||
call.ID = syntheticAgentToolCallID(body, index)
|
||
}
|
||
result.ToolCalls = append(result.ToolCalls, call)
|
||
}
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func syntheticAgentToolCallID(body []byte, index int) string {
|
||
sum := sha256.Sum256([]byte(fmt.Sprintf("%s#%d", body, index)))
|
||
return fmt.Sprintf("call_%x", sum[:8])
|
||
}
|
||
func (a manifestAdapter) ParseCreate(_ context.Context, body []byte) (CreateResult, error) {
|
||
if a.manifest.Response.BinaryPayload {
|
||
return binaryPayloadCreateResult(a.manifest.Response.ResultKind, body)
|
||
}
|
||
payload, err := decodeObject(body)
|
||
if err != nil {
|
||
return CreateResult{}, err
|
||
}
|
||
return a.parse(payload, PollContext{}), nil
|
||
}
|
||
|
||
// binaryPayloadCreateResult 把同步二进制响应包装为单个媒体结果。MIME 以响应内容探测为准,
|
||
// 空响应必须失败,不能把空内容伪装成生成成功。
|
||
func binaryPayloadCreateResult(resultKind string, body []byte) (CreateResult, error) {
|
||
if len(body) == 0 {
|
||
return CreateResult{}, fmt.Errorf("binary payload response is empty")
|
||
}
|
||
mimeType := strings.ToLower(strings.TrimSpace(strings.Split(http.DetectContentType(body), ";")[0]))
|
||
reference := MediaReference{DataURL: "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(body), MIMEType: mimeType}
|
||
result := &Result{}
|
||
switch resultKind {
|
||
case "audio":
|
||
result.Audios = []MediaReference{reference}
|
||
case "image":
|
||
result.Images = []MediaReference{reference}
|
||
case "video":
|
||
result.Videos = []MediaReference{reference}
|
||
default:
|
||
return CreateResult{}, fmt.Errorf("binary payload response requires resultKind image, video or audio, got %q", resultKind)
|
||
}
|
||
return CreateResult{Status: StatusSucceeded, Result: result}, nil
|
||
}
|
||
func (a manifestAdapter) BuildPoll(_ context.Context, c PollContext) (RequestSpec, error) {
|
||
if a.manifest.Poll == nil {
|
||
return RequestSpec{}, fmt.Errorf("protocol %s has no poll operation", a.manifest.Metadata.ID)
|
||
}
|
||
request := c.Request
|
||
request.Model = c.Model
|
||
return buildManifestOperation(*a.manifest.Poll, a.manifest.Auth, request, c.TaskID)
|
||
}
|
||
func (a manifestAdapter) ParsePoll(_ context.Context, c PollContext, body []byte) (PollResult, error) {
|
||
payload, err := decodeObject(body)
|
||
if err != nil {
|
||
return PollResult{}, err
|
||
}
|
||
result := a.parse(payload, c)
|
||
return PollResult{TaskID: result.TaskID, Status: result.Status, Result: result.Result, Message: result.Message}, nil
|
||
}
|
||
func (a manifestAdapter) BuildCancel(_ context.Context, c PollContext) (RequestSpec, error) {
|
||
if a.manifest.Cancel == nil {
|
||
return RequestSpec{}, fmt.Errorf("protocol %s does not support cancellation", a.manifest.Metadata.ID)
|
||
}
|
||
request := c.Request
|
||
request.Model = c.Model
|
||
return buildManifestOperation(*a.manifest.Cancel, a.manifest.Auth, request, c.TaskID)
|
||
}
|
||
|
||
func (a manifestAdapter) BuildResult(_ context.Context, c PollContext) (RequestSpec, error) {
|
||
if a.manifest.ResultOperation == nil {
|
||
return RequestSpec{}, fmt.Errorf("protocol %s has no result operation", a.manifest.Metadata.ID)
|
||
}
|
||
request := c.Request
|
||
request.Model = c.Model
|
||
return buildManifestOperation(*a.manifest.ResultOperation, a.manifest.Auth, request, c.TaskID)
|
||
}
|
||
|
||
func (a manifestAdapter) parse(payload map[string]any, c PollContext) CreateResult {
|
||
response := a.manifest.Response
|
||
env := map[string]any{"response": payload, "taskId": c.TaskID, "request": manifestRequestValues(c.Request)}
|
||
id := manifestResponseString(response.TaskID, env)
|
||
if id == "" {
|
||
id = firstPathValue(payload, response.TaskIDPaths...)
|
||
}
|
||
if id == "" {
|
||
id = c.TaskID
|
||
}
|
||
statusText := manifestResponseString(response.Status, env)
|
||
if statusText == "" {
|
||
statusText = firstPathValue(payload, response.StatusPaths...)
|
||
}
|
||
status := normalizeStatus(statusText)
|
||
if status == "" {
|
||
status = StatusPending
|
||
}
|
||
message := manifestResponseString(response.Message, env)
|
||
if message == "" {
|
||
message = firstPathValue(payload, response.MessagePaths...)
|
||
}
|
||
if manifestError(payload, response.ErrorPaths...) {
|
||
status = StatusFailed
|
||
}
|
||
result := &Result{
|
||
Text: manifestResponseString(response.Text, env),
|
||
Reasoning: manifestResponseString(response.Reasoning, env),
|
||
}
|
||
if result.Text == "" {
|
||
result.Text = firstPathValue(payload, response.TextPaths...)
|
||
}
|
||
if result.Reasoning == "" {
|
||
result.Reasoning = firstPathValue(payload, response.ReasoningPaths...)
|
||
}
|
||
result.Images = manifestResponseMedia(response.Images, env, "image", response.ResultEphemeral)
|
||
result.Videos = manifestResponseMedia(response.Videos, env, "video", response.ResultEphemeral)
|
||
result.Audios = manifestResponseMedia(response.Audios, env, "audio", response.ResultEphemeral)
|
||
if response.Usage != nil {
|
||
if value, err := evaluateManifestValue(response.Usage, env); err == nil {
|
||
result.Usage = manifestObject(value)
|
||
}
|
||
}
|
||
paths := append([]string{}, a.manifest.Response.ResultURLPaths...)
|
||
paths = append(paths, a.manifest.Response.ResultPaths...)
|
||
for _, path := range paths {
|
||
for _, value := range mediaPathValues(payload, path) {
|
||
item := MediaReference{URL: value, Kind: a.manifest.Response.ResultKind, Ephemeral: a.manifest.Response.ResultEphemeral}
|
||
switch a.manifest.Response.ResultKind {
|
||
case "image":
|
||
result.Images = append(result.Images, item)
|
||
case "audio":
|
||
result.Audios = append(result.Audios, item)
|
||
default:
|
||
result.Videos = append(result.Videos, item)
|
||
}
|
||
}
|
||
}
|
||
if status == StatusPending && (result.Text != "" || len(result.Images) > 0 || len(result.Videos) > 0 || len(result.Audios) > 0) {
|
||
status = StatusSucceeded
|
||
}
|
||
if result.Text == "" && result.Reasoning == "" && len(result.Images) == 0 && len(result.Videos) == 0 && len(result.Audios) == 0 {
|
||
result = nil
|
||
}
|
||
return CreateResult{TaskID: id, Status: status, Result: result, Message: message}
|
||
}
|
||
|
||
func validateManifestRequest(rules []ManifestValidation, request GenerationRequest) error {
|
||
if len(rules) == 0 {
|
||
return nil
|
||
}
|
||
env := map[string]any{"request": manifestRequestValues(request)}
|
||
for _, rule := range rules {
|
||
value, err := evaluateManifestValue(rule.Assert, env)
|
||
if err != nil {
|
||
return fmt.Errorf("协议参数校验表达式错误:%w", err)
|
||
}
|
||
if !manifestTruthy(value) {
|
||
return fmt.Errorf("%s", strings.TrimSpace(rule.Message))
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func manifestResponseString(template any, env map[string]any) string {
|
||
if template == nil {
|
||
return ""
|
||
}
|
||
value, err := evaluateManifestValue(template, env)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
items := manifestArray(value)
|
||
parts := make([]string, 0, len(items))
|
||
for _, item := range items {
|
||
if text := strings.TrimSpace(manifestString(item)); text != "" {
|
||
parts = append(parts, text)
|
||
}
|
||
}
|
||
return strings.Join(parts, "")
|
||
}
|
||
|
||
func manifestResponseMedia(template any, env map[string]any, kind string, ephemeral bool) []MediaReference {
|
||
if template == nil {
|
||
return nil
|
||
}
|
||
value, err := evaluateManifestValue(template, env)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
return mediaReferencesFromManifestValue(value, kind, ephemeral)
|
||
}
|
||
|
||
func mediaReferencesFromManifestValue(value any, kind string, ephemeral bool) []MediaReference {
|
||
result := make([]MediaReference, 0)
|
||
for _, item := range manifestArray(value) {
|
||
switch typed := item.(type) {
|
||
case string:
|
||
trimmed := strings.TrimSpace(typed)
|
||
if trimmed == "" {
|
||
continue
|
||
}
|
||
reference := MediaReference{Kind: kind, Ephemeral: ephemeral}
|
||
if strings.HasPrefix(trimmed, "data:") {
|
||
reference.DataURL = trimmed
|
||
} else {
|
||
reference.URL = trimmed
|
||
}
|
||
result = append(result, reference)
|
||
case map[string]any:
|
||
reference := MediaReference{
|
||
ID: manifestString(typed["id"]), URL: manifestString(typed["url"]), DataURL: manifestString(typed["dataUrl"]),
|
||
Kind: defaultValue(manifestString(typed["kind"]), kind), Role: manifestString(typed["role"]), MIMEType: manifestString(typed["mimeType"]),
|
||
Name: manifestString(typed["name"]), Order: manifestInt(typed["order"]), Weight: manifestFloat(typed["weight"]), Ephemeral: ephemeral || manifestTruthy(typed["ephemeral"]),
|
||
}
|
||
if reference.URL == "" {
|
||
reference.URL = firstString(typed, "file_url", "fileUrl", "image_url", "imageUrl", "video_url", "videoUrl", "audio_url", "audioUrl", "uri")
|
||
}
|
||
if reference.DataURL == "" {
|
||
reference.DataURL = firstString(typed, "data_url", "b64_json")
|
||
}
|
||
// OpenAI / Ark 等渠道常直接返回裸 b64_json;声明式结果下载要求 data URL。
|
||
reference.DataURL = normalizeManifestInlineDataURL(reference.DataURL, reference.Kind, firstString(typed, "output_format", "mime_type", "mimeType"))
|
||
if reference.URL != "" || reference.DataURL != "" {
|
||
result = append(result, reference)
|
||
}
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
func normalizeManifestInlineDataURL(value, kind, formatHint string) string {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" || strings.HasPrefix(value, "data:") {
|
||
return value
|
||
}
|
||
return "data:" + manifestInlineMediaMIME(kind, formatHint) + ";base64," + value
|
||
}
|
||
|
||
func manifestInlineMediaMIME(kind, formatHint string) string {
|
||
hint := strings.ToLower(strings.TrimSpace(formatHint))
|
||
switch hint {
|
||
case "image/png", "image/jpeg", "image/webp", "image/gif", "audio/mpeg", "audio/wav", "audio/ogg", "video/mp4", "video/webm":
|
||
return hint
|
||
case "image/jpg":
|
||
return "image/jpeg"
|
||
case "audio/mp3":
|
||
return "audio/mpeg"
|
||
case "png":
|
||
return "image/png"
|
||
case "jpeg", "jpg":
|
||
return "image/jpeg"
|
||
case "webp":
|
||
return "image/webp"
|
||
case "gif":
|
||
return "image/gif"
|
||
case "mp3", "mpeg":
|
||
return "audio/mpeg"
|
||
case "wav":
|
||
return "audio/wav"
|
||
case "mp4":
|
||
return "video/mp4"
|
||
case "webm":
|
||
return "video/webm"
|
||
}
|
||
switch strings.ToLower(strings.TrimSpace(kind)) {
|
||
case "audio":
|
||
return "audio/mpeg"
|
||
case "video":
|
||
return "video/mp4"
|
||
default:
|
||
return "image/png"
|
||
}
|
||
}
|
||
|
||
var (
|
||
manifestMDImageRegex = regexp.MustCompile(`!\[[^\]]*\]\(([^)\s]+)\)`)
|
||
manifestHTMLVideoRegex = regexp.MustCompile(`(?i)<video[^>]+src=['"]([^'"]+)['"]`)
|
||
)
|
||
|
||
func mediaPathValues(payload map[string]any, path string) []string {
|
||
value := pathValue(payload, path)
|
||
if text, ok := value.(string); ok && strings.TrimSpace(text) != "" {
|
||
trimmed := strings.TrimSpace(text)
|
||
if matches := manifestMDImageRegex.FindAllStringSubmatch(trimmed, -1); len(matches) > 0 {
|
||
var res []string
|
||
for _, m := range matches {
|
||
if len(m) > 1 && m[1] != "" {
|
||
res = append(res, m[1])
|
||
}
|
||
}
|
||
if len(res) > 0 {
|
||
return res
|
||
}
|
||
}
|
||
if matches := manifestHTMLVideoRegex.FindAllStringSubmatch(trimmed, -1); len(matches) > 0 {
|
||
var res []string
|
||
for _, m := range matches {
|
||
if len(m) > 1 && m[1] != "" {
|
||
res = append(res, m[1])
|
||
}
|
||
}
|
||
if len(res) > 0 {
|
||
return res
|
||
}
|
||
}
|
||
return []string{trimmed}
|
||
}
|
||
items, ok := value.([]any)
|
||
if !ok {
|
||
return nil
|
||
}
|
||
values := make([]string, 0, len(items))
|
||
for _, item := range items {
|
||
switch typed := item.(type) {
|
||
case string:
|
||
if strings.TrimSpace(typed) != "" {
|
||
values = append(values, strings.TrimSpace(typed))
|
||
}
|
||
case map[string]any:
|
||
if url := firstString(typed, "url", "file_url", "fileUrl", "video_url", "videoUrl", "image_url", "imageUrl"); url != "" {
|
||
values = append(values, url)
|
||
}
|
||
}
|
||
}
|
||
return values
|
||
}
|
||
|
||
func buildManifestOperation(operation ManifestOperation, auth ManifestAuth, request GenerationRequest, taskID string) (RequestSpec, error) {
|
||
requestValues := manifestRequestValues(request)
|
||
env := map[string]any{"request": requestValues, "taskId": taskID}
|
||
var body any
|
||
if operation.Body != nil {
|
||
value, err := evaluateManifestValue(operation.Body, env)
|
||
if err != nil {
|
||
return RequestSpec{}, fmt.Errorf("evaluate request body: %w", err)
|
||
}
|
||
body = normalizeManifestValue(value)
|
||
} else if len(operation.Fields) > 0 {
|
||
legacyBody := make(map[string]any, len(operation.Fields))
|
||
for key, expression := range operation.Fields {
|
||
value := manifestExpressionValue(expression, request, taskID)
|
||
if value == nil {
|
||
continue
|
||
}
|
||
setMapPath(legacyBody, key, value)
|
||
}
|
||
body = normalizeManifestValue(legacyBody)
|
||
}
|
||
pathValue := any(operation.Path)
|
||
if operation.PathTemplate != nil {
|
||
pathValue = operation.PathTemplate
|
||
}
|
||
evaluatedPath, err := evaluateManifestValue(pathValue, env)
|
||
if err != nil {
|
||
return RequestSpec{}, fmt.Errorf("evaluate request path: %w", err)
|
||
}
|
||
path := strings.ReplaceAll(manifestString(evaluatedPath), "{{taskId}}", url.PathEscape(taskID))
|
||
path = strings.ReplaceAll(path, "{{model}}", url.PathEscape(request.Model))
|
||
path = interpolateManifestString(path, env)
|
||
if !isRelativePath(path) {
|
||
return RequestSpec{}, fmt.Errorf("evaluated request path must be relative: %q", path)
|
||
}
|
||
headers, err := evaluateManifestStringMap(operation.Headers, env)
|
||
if err != nil {
|
||
return RequestSpec{}, fmt.Errorf("evaluate request headers: %w", err)
|
||
}
|
||
query, err := evaluateManifestQuery(operation.Query, env)
|
||
if err != nil {
|
||
return RequestSpec{}, fmt.Errorf("evaluate request query: %w", err)
|
||
}
|
||
files, err := evaluateManifestFiles(operation.Files, env)
|
||
if err != nil {
|
||
return RequestSpec{}, err
|
||
}
|
||
contentTypeValue := any(operation.ContentType)
|
||
if operation.ContentTypeTemplate != nil {
|
||
contentTypeValue = operation.ContentTypeTemplate
|
||
}
|
||
evaluatedContentType, err := evaluateManifestValue(contentTypeValue, env)
|
||
if err != nil {
|
||
return RequestSpec{}, fmt.Errorf("evaluate request content type: %w", err)
|
||
}
|
||
contentType := defaultValue(manifestString(evaluatedContentType), "application/json")
|
||
return RequestSpec{Method: strings.ToUpper(operation.Method), Path: path, OriginPath: operation.OriginPath, ContentType: contentType, Headers: headers, Query: query, Body: body, Files: files, Auth: auth}, nil
|
||
}
|
||
|
||
func evaluateManifestStringMap(values map[string]any, env map[string]any) (map[string]string, error) {
|
||
if len(values) == 0 {
|
||
return nil, nil
|
||
}
|
||
result := make(map[string]string, len(values))
|
||
for key, template := range values {
|
||
value, err := evaluateManifestValue(template, env)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if text := strings.TrimSpace(manifestString(value)); text != "" {
|
||
result[key] = text
|
||
}
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func evaluateManifestQuery(values map[string]any, env map[string]any) (map[string][]string, error) {
|
||
if len(values) == 0 {
|
||
return nil, nil
|
||
}
|
||
result := make(map[string][]string, len(values))
|
||
for key, template := range values {
|
||
value, err := evaluateManifestValue(template, env)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for _, item := range manifestArray(value) {
|
||
if text := strings.TrimSpace(manifestString(item)); text != "" {
|
||
result[key] = append(result[key], text)
|
||
}
|
||
}
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func evaluateManifestFiles(parts []ManifestFilePart, env map[string]any) ([]RequestFilePart, error) {
|
||
result := make([]RequestFilePart, 0)
|
||
for _, part := range parts {
|
||
source, err := evaluateManifestValue(part.Source, env)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("evaluate multipart file %q: %w", part.Name, err)
|
||
}
|
||
filename, err := evaluateManifestValue(part.Filename, env)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("evaluate multipart filename %q: %w", part.Name, err)
|
||
}
|
||
mimeType, err := evaluateManifestValue(part.MIMEType, env)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("evaluate multipart MIME type %q: %w", part.Name, err)
|
||
}
|
||
for index, value := range manifestArray(source) {
|
||
references := mediaReferencesFromManifestValue(value, "file", false)
|
||
for _, reference := range references {
|
||
name := strings.TrimSpace(manifestString(filename))
|
||
if name == "" {
|
||
name = reference.Name
|
||
}
|
||
if name == "" {
|
||
name = fmt.Sprintf("%s-%d", part.Name, index+1)
|
||
}
|
||
contentType := strings.TrimSpace(manifestString(mimeType))
|
||
if contentType == "" {
|
||
contentType = reference.MIMEType
|
||
}
|
||
result = append(result, RequestFilePart{Name: part.Name, Filename: name, MIMEType: contentType, Reference: reference})
|
||
}
|
||
}
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func normalizeManifestValue(value any) any {
|
||
switch v := value.(type) {
|
||
case map[string]any:
|
||
if len(v) == 0 {
|
||
return v
|
||
}
|
||
isSequential := true
|
||
for i := 0; i < len(v); i++ {
|
||
if _, ok := v[strconv.Itoa(i)]; !ok {
|
||
isSequential = false
|
||
break
|
||
}
|
||
}
|
||
if isSequential {
|
||
arr := make([]any, len(v))
|
||
for i := 0; i < len(v); i++ {
|
||
arr[i] = normalizeManifestValue(v[strconv.Itoa(i)])
|
||
}
|
||
return arr
|
||
}
|
||
res := make(map[string]any, len(v))
|
||
for k, val := range v {
|
||
res[k] = normalizeManifestValue(val)
|
||
}
|
||
return res
|
||
case []any:
|
||
arr := make([]any, len(v))
|
||
for i, val := range v {
|
||
arr[i] = normalizeManifestValue(val)
|
||
}
|
||
return arr
|
||
default:
|
||
return value
|
||
}
|
||
}
|
||
|
||
func manifestExpressionValue(expression string, request GenerationRequest, taskID string) any {
|
||
expression = strings.TrimSpace(expression)
|
||
source := expression
|
||
transforms := []string{}
|
||
if separator := strings.IndexByte(expression, '|'); separator >= 0 {
|
||
source = strings.TrimSpace(expression[:separator])
|
||
transforms = strings.Split(expression[separator+1:], "|")
|
||
}
|
||
|
||
var value any
|
||
switch {
|
||
case source == "taskId":
|
||
value = taskID
|
||
case strings.HasPrefix(source, "request."):
|
||
value = pathValue(manifestRequestValues(request), strings.TrimPrefix(source, "request."))
|
||
default:
|
||
value = source
|
||
}
|
||
for _, transform := range transforms {
|
||
value = applyManifestTransform(value, transform, request)
|
||
if value == nil {
|
||
break
|
||
}
|
||
}
|
||
return value
|
||
}
|
||
|
||
// manifestRequestValues is deliberately a small, JSON-shaped view of the
|
||
// platform request. It lets uploaded manifests address media items by index
|
||
// without exposing Go structs or adding provider-specific host code.
|
||
func manifestRequestValues(request GenerationRequest) map[string]any {
|
||
userContent := make([]any, 0)
|
||
if strings.TrimSpace(request.Prompt) != "" {
|
||
userContent = append(userContent, map[string]any{"type": "text", "text": request.Prompt})
|
||
}
|
||
for _, img := range request.Images {
|
||
val := defaultValue(img.URL, img.DataURL)
|
||
if val != "" {
|
||
userContent = append(userContent, map[string]any{"type": "image_url", "image_url": map[string]any{"url": val}})
|
||
}
|
||
}
|
||
for _, vid := range request.Videos {
|
||
val := defaultValue(vid.URL, vid.DataURL)
|
||
if val != "" {
|
||
userContent = append(userContent, map[string]any{"type": "video_url", "video_url": map[string]any{"url": val}})
|
||
}
|
||
}
|
||
for _, aud := range request.Audios {
|
||
val := defaultValue(aud.URL, aud.DataURL)
|
||
if val != "" {
|
||
userContent = append(userContent, map[string]any{"type": "audio_url", "audio_url": map[string]any{"url": val}})
|
||
}
|
||
}
|
||
messages := make([]any, 0, len(request.Messages)+2)
|
||
// instructions 是统一的系统指令字段,但多数 OpenAI 系协议只映射 request.messages。
|
||
// 系统指令必须进入消息数组,否则会被静默丢弃;带独立 system 字段的协议(Claude、
|
||
// Gemini、Responses)在各自模板里过滤 system 角色,不会重复发送。
|
||
if instructions := strings.TrimSpace(request.Instructions); instructions != "" && !hasSystemMessage(request.Messages) {
|
||
messages = append(messages, map[string]any{"role": "system", "content": instructions})
|
||
}
|
||
for _, message := range request.Messages {
|
||
if strings.TrimSpace(message.Role) == "" || message.Content == nil {
|
||
continue
|
||
}
|
||
messages = append(messages, map[string]any{"role": message.Role, "content": message.Content})
|
||
}
|
||
if len(userContent) > 0 {
|
||
content := any(userContent)
|
||
if len(userContent) == 1 && len(request.Images)+len(request.Videos)+len(request.Audios) == 0 {
|
||
content = request.Prompt
|
||
}
|
||
messages = append(messages, map[string]any{"role": "user", "content": content})
|
||
}
|
||
|
||
inputs := request.Inputs
|
||
if len(inputs) == 0 {
|
||
inputs = append(inputs, request.Images...)
|
||
inputs = append(inputs, request.Videos...)
|
||
inputs = append(inputs, request.Audios...)
|
||
}
|
||
output := request.Output
|
||
if output.Count == 0 {
|
||
output.Count = request.ImageCount
|
||
}
|
||
if output.Duration == 0 {
|
||
output.Duration = request.Duration
|
||
}
|
||
if output.AspectRatio == "" {
|
||
output.AspectRatio = request.AspectRatio
|
||
}
|
||
if output.Resolution == "" {
|
||
output.Resolution = request.Resolution
|
||
}
|
||
if output.Quality == "" {
|
||
output.Quality = request.Quality
|
||
}
|
||
output.GenerateAudio = output.GenerateAudio || request.GenerateAudio
|
||
output.Watermark = output.Watermark || request.Watermark
|
||
outputValue, _ := requestAsManifestValue(output)
|
||
|
||
return map[string]any{
|
||
"capability": request.Capability,
|
||
"model": request.Model,
|
||
"prompt": request.Prompt,
|
||
"instructions": request.Instructions,
|
||
"messages": messages,
|
||
"inputs": manifestMediaValues(inputs),
|
||
"images": manifestMediaValues(request.Images),
|
||
"videos": manifestMediaValues(request.Videos),
|
||
"audios": manifestMediaValues(request.Audios),
|
||
"imageCount": request.ImageCount,
|
||
"duration": request.Duration,
|
||
"aspectRatio": request.AspectRatio,
|
||
"resolution": request.Resolution,
|
||
"quality": request.Quality,
|
||
"generateAudio": request.GenerateAudio,
|
||
"watermark": request.Watermark,
|
||
"operation": request.Operation,
|
||
"output": outputValue,
|
||
"providerOptions": request.ProviderOptions,
|
||
"extra": request.Extra,
|
||
}
|
||
}
|
||
|
||
func hasSystemMessage(messages []Message) bool {
|
||
for _, message := range messages {
|
||
if strings.EqualFold(strings.TrimSpace(message.Role), "system") {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func requestAsManifestValue(value any) (any, error) {
|
||
encoded, err := json.Marshal(value)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var result any
|
||
if err := json.Unmarshal(encoded, &result); err != nil {
|
||
return nil, err
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func manifestModelID(request GenerationRequest) string {
|
||
modelID := strings.TrimSpace(request.Model)
|
||
if separator := strings.LastIndex(modelID, "::"); separator >= 0 {
|
||
modelID = modelID[separator+2:]
|
||
}
|
||
return strings.ToLower(modelID)
|
||
}
|
||
|
||
func manifestMediaValues(values []MediaReference) []any {
|
||
result := make([]any, 0, len(values))
|
||
for index, value := range values {
|
||
order := value.Order
|
||
if order == 0 && index > 0 {
|
||
order = index
|
||
}
|
||
resolved := defaultValue(value.URL, value.DataURL)
|
||
result = append(result, map[string]any{
|
||
"id": value.ID,
|
||
"url": value.URL,
|
||
"dataUrl": value.DataURL,
|
||
"value": resolved,
|
||
"kind": value.Kind,
|
||
"role": value.Role,
|
||
"mimeType": value.MIMEType,
|
||
"name": value.Name,
|
||
"order": order,
|
||
"weight": value.Weight,
|
||
"metadata": value.Metadata,
|
||
"source": map[string]any{
|
||
"type": manifestMediaSourceType(value),
|
||
"value": resolved,
|
||
"mimeType": value.MIMEType,
|
||
},
|
||
"ephemeral": value.Ephemeral,
|
||
})
|
||
}
|
||
return result
|
||
}
|
||
|
||
func manifestMediaSourceType(value MediaReference) string {
|
||
if value.DataURL != "" {
|
||
return "data"
|
||
}
|
||
if value.URL != "" {
|
||
return "url"
|
||
}
|
||
return "unknown"
|
||
}
|
||
|
||
func applyManifestTransform(value any, transform string, request GenerationRequest) any {
|
||
transform = strings.ToLower(strings.TrimSpace(transform))
|
||
if transform == "bool" || transform == "boolean" {
|
||
switch v := value.(type) {
|
||
case bool:
|
||
return v
|
||
case string:
|
||
s := strings.ToLower(strings.TrimSpace(v))
|
||
return s == "true" || s == "1" || s == "yes"
|
||
case int, int64, float64:
|
||
return v != 0
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
if transform == "int" || transform == "integer" {
|
||
switch v := value.(type) {
|
||
case int:
|
||
return v
|
||
case int64:
|
||
return int(v)
|
||
case float64:
|
||
return int(v)
|
||
case string:
|
||
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
|
||
return n
|
||
}
|
||
return 0
|
||
default:
|
||
return 0
|
||
}
|
||
}
|
||
if transform == "omit_zero" {
|
||
switch v := value.(type) {
|
||
case int:
|
||
if v == 0 {
|
||
return nil
|
||
}
|
||
case int64:
|
||
if v == 0 {
|
||
return nil
|
||
}
|
||
case float64:
|
||
if v == 0 {
|
||
return nil
|
||
}
|
||
case string:
|
||
if strings.TrimSpace(v) == "0" || strings.TrimSpace(v) == "" {
|
||
return nil
|
||
}
|
||
}
|
||
}
|
||
cleanModel := manifestModelID(request)
|
||
if strings.HasPrefix(transform, "omit_unless_model_contains:") {
|
||
needle := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(transform, "omit_unless_model_contains:")))
|
||
if needle == "" || !strings.Contains(cleanModel, needle) {
|
||
return nil
|
||
}
|
||
}
|
||
if strings.HasPrefix(transform, "omit_if_model_contains:") {
|
||
needle := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(transform, "omit_if_model_contains:")))
|
||
if needle != "" && strings.Contains(cleanModel, needle) {
|
||
return nil
|
||
}
|
||
}
|
||
if strings.HasPrefix(transform, "omit_unless_model_equals:") {
|
||
target := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(transform, "omit_unless_model_equals:")))
|
||
if target == "" || cleanModel != target {
|
||
return nil
|
||
}
|
||
}
|
||
if strings.HasPrefix(transform, "omit_if_model_equals:") {
|
||
target := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(transform, "omit_if_model_equals:")))
|
||
if target != "" && cleanModel == target {
|
||
return nil
|
||
}
|
||
}
|
||
text, ok := value.(string)
|
||
if transform == "omit_empty" && (!ok || strings.TrimSpace(text) == "") {
|
||
return nil
|
||
}
|
||
if transform == "omit_auto" && (!ok || strings.TrimSpace(text) == "" || strings.EqualFold(strings.TrimSpace(text), "auto") || strings.EqualFold(strings.TrimSpace(text), "default")) {
|
||
return nil
|
||
}
|
||
if !ok {
|
||
return value
|
||
}
|
||
switch transform {
|
||
case "trim":
|
||
return strings.TrimSpace(text)
|
||
case "lower", "lowercase":
|
||
return strings.ToLower(text)
|
||
case "upper", "uppercase":
|
||
return strings.ToUpper(text)
|
||
case "resolution_p":
|
||
if regexp.MustCompile(`^\d+$`).MatchString(strings.TrimSpace(text)) {
|
||
return strings.TrimSpace(text) + "p"
|
||
}
|
||
return text
|
||
case "video-resolution", "video_resolution", "videoresolution":
|
||
// Compatibility for already-installed 1.0.1 manifests. New plugins
|
||
// should declare exact enum values and use generic string transforms.
|
||
return normalizeLegacyManifestVideoResolution(text)
|
||
default:
|
||
return value
|
||
}
|
||
}
|
||
|
||
func normalizeLegacyManifestVideoResolution(value string) string {
|
||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||
if normalized == "" {
|
||
return ""
|
||
}
|
||
for _, char := range normalized {
|
||
if char < '0' || char > '9' {
|
||
return normalized
|
||
}
|
||
}
|
||
return normalized + "p"
|
||
}
|
||
|
||
func pathValue(payload map[string]any, path string) any {
|
||
return manifestPathValue(payload, path)
|
||
}
|
||
|
||
func arrayValue(value any) []any {
|
||
items, _ := value.([]any)
|
||
return items
|
||
}
|
||
|
||
func firstPathValue(payload map[string]any, paths ...string) string {
|
||
for _, path := range paths {
|
||
value := pathValue(payload, path)
|
||
if text, ok := value.(string); ok && strings.TrimSpace(text) != "" {
|
||
return strings.TrimSpace(text)
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func manifestError(payload map[string]any, paths ...string) bool {
|
||
for _, path := range paths {
|
||
value := pathValue(payload, path)
|
||
switch typed := value.(type) {
|
||
case nil:
|
||
continue
|
||
case string:
|
||
switch strings.ToLower(strings.TrimSpace(typed)) {
|
||
case "", "0", "ok", "success", "succeeded", "true":
|
||
continue
|
||
default:
|
||
return true
|
||
}
|
||
case float64:
|
||
if typed == 0 {
|
||
continue
|
||
}
|
||
return true
|
||
case float32:
|
||
if typed == 0 {
|
||
continue
|
||
}
|
||
return true
|
||
case int, int8, int16, int32, int64:
|
||
if reflectValueIsZero(typed) {
|
||
continue
|
||
}
|
||
return true
|
||
case uint, uint8, uint16, uint32, uint64:
|
||
if reflectValueIsZero(typed) {
|
||
continue
|
||
}
|
||
return true
|
||
default:
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func reflectValueIsZero(value any) bool {
|
||
switch typed := value.(type) {
|
||
case int:
|
||
return typed == 0
|
||
case int8:
|
||
return typed == 0
|
||
case int16:
|
||
return typed == 0
|
||
case int32:
|
||
return typed == 0
|
||
case int64:
|
||
return typed == 0
|
||
case uint:
|
||
return typed == 0
|
||
case uint8:
|
||
return typed == 0
|
||
case uint16:
|
||
return typed == 0
|
||
case uint32:
|
||
return typed == 0
|
||
case uint64:
|
||
return typed == 0
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// firstPathJSONValue keeps scalar response fields string-like while allowing
|
||
// providers to return tool arguments as an object instead of a JSON string.
|
||
// The platform tool contract always stores arguments as JSON text.
|
||
func firstPathJSONValue(payload map[string]any, paths ...string) string {
|
||
for _, path := range paths {
|
||
value := pathValue(payload, path)
|
||
switch typed := value.(type) {
|
||
case string:
|
||
if strings.TrimSpace(typed) != "" {
|
||
return strings.TrimSpace(typed)
|
||
}
|
||
case nil:
|
||
continue
|
||
default:
|
||
encoded, err := json.Marshal(typed)
|
||
if err == nil && len(encoded) > 0 {
|
||
return string(encoded)
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func setMapPath(target map[string]any, path string, value any) {
|
||
parts := strings.Split(strings.Trim(path, "."), ".")
|
||
current := target
|
||
for _, part := range parts[:len(parts)-1] {
|
||
next, ok := current[part].(map[string]any)
|
||
if !ok {
|
||
next = map[string]any{}
|
||
current[part] = next
|
||
}
|
||
current = next
|
||
}
|
||
if len(parts) > 0 {
|
||
current[parts[len(parts)-1]] = value
|
||
}
|
||
}
|
||
|
||
func isRelativePath(path string) bool {
|
||
parsed, err := url.Parse(strings.TrimSpace(path))
|
||
return err == nil && parsed.Path != "" && strings.HasPrefix(parsed.Path, "/") && !strings.HasPrefix(parsed.Path, "//") && parsed.Host == "" && parsed.User == nil && parsed.Scheme == ""
|
||
}
|
||
|
||
func validManifestIdentifier(value string) bool {
|
||
if len(value) == 0 || len(value) > 96 {
|
||
return false
|
||
}
|
||
for index, char := range value {
|
||
if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '-' || char == '_' || char == '.' {
|
||
if index == 0 && (char == '-' || char == '_' || char == '.') {
|
||
return false
|
||
}
|
||
continue
|
||
}
|
||
return false
|
||
}
|
||
return true
|
||
}
|