AI网关新增TTS/重排/审核端点,xAI工具扩展,swagger修缺
- 新端点 /ai/v1/audio/speech(xai.grok-tts)、/rerank(cohere.rerank-v4)、/moderations(OCI Guardrails) - Responses 放行 code_interpreter 与远程 mcp 工具,web_search/x_search 解除仅非流式限制 - 模型能力映射扩展:TEXT_RERANK→RERANK、TEXT_TO_AUDIO→TTS - AI 网关文档独立 docs/ai-gateway.md,字段兼容矩阵只列支持项;README 精简引用 - swagger 修缺:135 处响应注解具体化,RawMessage/联合类型统一渲染 AnyJSON,overrides 迁至 docs/.swaggo - CHANGELOG 0.4.0,版本段不再记日期;DASH_VERSION v0.4.0
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"oci-portal/internal/aiwire"
|
||||
"oci-portal/internal/model"
|
||||
"oci-portal/internal/oci"
|
||||
)
|
||||
|
||||
// moderationsMaxInputs 限制单次审核条数,防止批量滥用拖垮渠道配额。
|
||||
const moderationsMaxInputs = 8
|
||||
|
||||
// SpeechBodyNormalize 校验并规范化 TTS 请求体:model / input 必填;
|
||||
// xAI 上游把 language 当必填(实测缺失返回 422),缺省注入 "auto"。
|
||||
// 其余字段(voice / response_format / extra_body 平铺项)原样保留。
|
||||
func SpeechBodyNormalize(raw []byte) (string, []byte, error) {
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.UseNumber()
|
||||
var body map[string]any
|
||||
if err := dec.Decode(&body); err != nil {
|
||||
return "", nil, fmt.Errorf("解析请求体: %w", err)
|
||||
}
|
||||
modelName, _ := body["model"].(string)
|
||||
input, _ := body["input"].(string)
|
||||
if strings.TrimSpace(modelName) == "" || strings.TrimSpace(input) == "" {
|
||||
return "", nil, fmt.Errorf("model 与 input 不能为空")
|
||||
}
|
||||
if lang, ok := body["language"].(string); !ok || strings.TrimSpace(lang) == "" {
|
||||
body["language"] = "auto"
|
||||
}
|
||||
out, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("重组请求体: %w", err)
|
||||
}
|
||||
return modelName, out, nil
|
||||
}
|
||||
|
||||
// Speech 编排 TTS 调用:按 TTS 能力选渠道,可重试错误换渠道(上限 3 次)。
|
||||
func (s *AiGatewayService) Speech(ctx context.Context, modelName string, body []byte, group string) ([]byte, string, ChatMeta, error) {
|
||||
meta := ChatMeta{}
|
||||
excluded := map[uint]bool{}
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
cand, err := s.pick(ctx, modelName, group, "TTS", excluded)
|
||||
if err != nil {
|
||||
return nil, "", meta, firstErr(lastErr, err)
|
||||
}
|
||||
meta.ChannelID, meta.ChannelName = cand.ch.ID, cand.ch.Name
|
||||
cred, err := s.configs.credentialsByID(ctx, cand.ch.OciConfigID)
|
||||
if err != nil {
|
||||
return nil, "", meta, err
|
||||
}
|
||||
audio, contentType, err := s.client.GenAiCompatSpeech(ctx, cred, cand.ch.Region, body)
|
||||
if err == nil {
|
||||
s.markSuccess(ctx, cand.ch.ID)
|
||||
return audio, contentType, meta, nil
|
||||
}
|
||||
if done := s.retryStep(ctx, cand.ch.ID, err, excluded, &meta, &lastErr); done {
|
||||
return nil, "", meta, err
|
||||
}
|
||||
}
|
||||
return nil, "", meta, lastErr
|
||||
}
|
||||
|
||||
// Rerank 编排文档重排:按 RERANK 能力选渠道,装配 Jina 风格响应。
|
||||
func (s *AiGatewayService) Rerank(ctx context.Context, req aiwire.RerankRequest, group string) (*aiwire.RerankResponse, ChatMeta, error) {
|
||||
meta := ChatMeta{}
|
||||
excluded := map[uint]bool{}
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
cand, err := s.pick(ctx, req.Model, group, "RERANK", excluded)
|
||||
if err != nil {
|
||||
return nil, meta, firstErr(lastErr, err)
|
||||
}
|
||||
meta.ChannelID, meta.ChannelName = cand.ch.ID, cand.ch.Name
|
||||
cred, err := s.configs.credentialsByID(ctx, cand.ch.OciConfigID)
|
||||
if err != nil {
|
||||
return nil, meta, err
|
||||
}
|
||||
ranks, err := s.client.GenAiRerank(ctx, cred, cand.ch.Region, cand.modelOcid, req.Query, req.Documents, req.TopN)
|
||||
if err == nil {
|
||||
s.markSuccess(ctx, cand.ch.ID)
|
||||
return rerankResponse(req, ranks), meta, nil
|
||||
}
|
||||
if done := s.retryStep(ctx, cand.ch.ID, err, excluded, &meta, &lastErr); done {
|
||||
return nil, meta, err
|
||||
}
|
||||
}
|
||||
return nil, meta, lastErr
|
||||
}
|
||||
|
||||
// retryStep 统一处理换渠道重试:不可重试返回 true 终止,可重试记罚并排除渠道。
|
||||
func (s *AiGatewayService) retryStep(ctx context.Context, chID uint, err error, excluded map[uint]bool, meta *ChatMeta, lastErr *error) bool {
|
||||
retry, penalize := switchable(err)
|
||||
if !retry {
|
||||
return true
|
||||
}
|
||||
if penalize {
|
||||
s.markFailure(ctx, chID)
|
||||
}
|
||||
excluded[chID] = true
|
||||
meta.Retries++
|
||||
*lastErr = err
|
||||
return false
|
||||
}
|
||||
|
||||
// rerankResponse 把上游排序结果装配为对外响应;return_documents 时回填原文。
|
||||
func rerankResponse(req aiwire.RerankRequest, ranks []oci.RerankRank) *aiwire.RerankResponse {
|
||||
out := &aiwire.RerankResponse{Model: req.Model, Results: make([]aiwire.RerankResult, 0, len(ranks))}
|
||||
withDoc := req.ReturnDocuments != nil && *req.ReturnDocuments
|
||||
for _, r := range ranks {
|
||||
if r.Index < 0 || r.Index >= len(req.Documents) {
|
||||
continue
|
||||
}
|
||||
item := aiwire.RerankResult{Index: r.Index, RelevanceScore: r.Score}
|
||||
if withDoc {
|
||||
item.Document = &aiwire.RerankDocument{Text: req.Documents[r.Index]}
|
||||
}
|
||||
out.Results = append(out.Results, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ModerationInputs 解析 moderations 的 input 字段:string 或 []string,
|
||||
// 条数与空值校验(上限 moderationsMaxInputs)。
|
||||
func ModerationInputs(raw json.RawMessage) ([]string, error) {
|
||||
var single string
|
||||
if err := json.Unmarshal(raw, &single); err == nil {
|
||||
if strings.TrimSpace(single) == "" {
|
||||
return nil, fmt.Errorf("input 不能为空")
|
||||
}
|
||||
return []string{single}, nil
|
||||
}
|
||||
var many []string
|
||||
if err := json.Unmarshal(raw, &many); err != nil {
|
||||
return nil, fmt.Errorf("input 需为字符串或字符串数组")
|
||||
}
|
||||
if len(many) == 0 || len(many) > moderationsMaxInputs {
|
||||
return nil, fmt.Errorf("input 条数需在 1~%d 之间", moderationsMaxInputs)
|
||||
}
|
||||
for _, item := range many {
|
||||
if strings.TrimSpace(item) == "" {
|
||||
return nil, fmt.Errorf("input 含空条目")
|
||||
}
|
||||
}
|
||||
return many, nil
|
||||
}
|
||||
|
||||
// Moderations 编排内容审核:guardrails 为服务级 API 无模型维度,
|
||||
// 按分组选启用渠道(整批同渠道),可重试错误换渠道(上限 3 次)。
|
||||
func (s *AiGatewayService) Moderations(ctx context.Context, id string, inputs []string, group string) (*aiwire.ModerationsResponse, ChatMeta, error) {
|
||||
meta := ChatMeta{}
|
||||
excluded := map[uint]bool{}
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
ch, err := s.pickGuardChannel(ctx, group, excluded)
|
||||
if err != nil {
|
||||
return nil, meta, firstErr(lastErr, err)
|
||||
}
|
||||
meta.ChannelID, meta.ChannelName = ch.ID, ch.Name
|
||||
resp, err := s.moderateOnce(ctx, ch, id, inputs)
|
||||
if err == nil {
|
||||
s.markSuccess(ctx, ch.ID)
|
||||
return resp, meta, nil
|
||||
}
|
||||
if done := s.retryStep(ctx, ch.ID, err, excluded, &meta, &lastErr); done {
|
||||
return nil, meta, err
|
||||
}
|
||||
}
|
||||
return nil, meta, lastErr
|
||||
}
|
||||
|
||||
// pickGuardChannel 按分组挑一个启用且未熔断的渠道(权重加成,不查模型缓存)。
|
||||
func (s *AiGatewayService) pickGuardChannel(ctx context.Context, group string, excluded map[uint]bool) (model.AiChannel, error) {
|
||||
q := s.db.WithContext(ctx).Where("enabled = ?", true)
|
||||
if group != "" {
|
||||
q = q.Where("channel_group = ?", group)
|
||||
}
|
||||
var channels []model.AiChannel
|
||||
if err := q.Find(&channels).Error; err != nil {
|
||||
return model.AiChannel{}, err
|
||||
}
|
||||
now := time.Now()
|
||||
avail := channels[:0]
|
||||
for _, ch := range channels {
|
||||
if excluded[ch.ID] || (ch.DisabledUntil != nil && ch.DisabledUntil.After(now)) {
|
||||
continue
|
||||
}
|
||||
avail = append(avail, ch)
|
||||
}
|
||||
if len(avail) == 0 {
|
||||
return model.AiChannel{}, ErrAiNoChannel
|
||||
}
|
||||
return weightedPick(topPriority(avail)), nil
|
||||
}
|
||||
|
||||
// moderateOnce 在单渠道上逐条审核并装配 OpenAI moderations 外壳。
|
||||
func (s *AiGatewayService) moderateOnce(ctx context.Context, ch model.AiChannel, id string, inputs []string) (*aiwire.ModerationsResponse, error) {
|
||||
cred, err := s.configs.credentialsByID(ctx, ch.OciConfigID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := &aiwire.ModerationsResponse{ID: id, Model: "oci-guardrails",
|
||||
Results: make([]aiwire.ModerationResult, 0, len(inputs))}
|
||||
for _, text := range inputs {
|
||||
outcome, err := s.client.GenAiApplyGuardrails(ctx, cred, ch.Region, text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.Results = append(out.Results, moderationResult(outcome))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// moderationFlagThreshold 是判定违规的得分阈值(上游 CM / PI 为二值得分)。
|
||||
const moderationFlagThreshold = 0.5
|
||||
|
||||
// moderationResult 把 guardrails 结果映射为 OpenAI moderations 条目:
|
||||
// flagged 由内容审核与提示注入判定,PII 命中只作扩展信息不参与 flagged。
|
||||
func moderationResult(outcome *oci.GuardrailsOutcome) aiwire.ModerationResult {
|
||||
res := aiwire.ModerationResult{Categories: map[string]bool{}, CategoryScores: map[string]float64{}}
|
||||
for _, cat := range outcome.Categories {
|
||||
key := strings.ToLower(cat.Name)
|
||||
res.Categories[key] = cat.Score >= moderationFlagThreshold
|
||||
res.CategoryScores[key] = cat.Score
|
||||
if res.Categories[key] {
|
||||
res.Flagged = true
|
||||
}
|
||||
}
|
||||
if outcome.PromptInjectionScore != nil {
|
||||
hit := *outcome.PromptInjectionScore >= moderationFlagThreshold
|
||||
res.Categories["prompt_injection"] = hit
|
||||
res.CategoryScores["prompt_injection"] = *outcome.PromptInjectionScore
|
||||
if hit {
|
||||
res.Flagged = true
|
||||
}
|
||||
}
|
||||
for _, p := range outcome.Pii {
|
||||
res.Pii = append(res.Pii, aiwire.ModerationPii{Text: p.Text, Label: p.Label,
|
||||
Score: p.Score, Offset: p.Offset, Length: p.Length})
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"oci-portal/internal/aiwire"
|
||||
"oci-portal/internal/model"
|
||||
"oci-portal/internal/oci"
|
||||
)
|
||||
|
||||
// TestSpeechBodyNormalize 断言 TTS 请求体校验与 language 缺省注入。
|
||||
func TestSpeechBodyNormalize(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantErr bool
|
||||
wantLang string
|
||||
}{
|
||||
{"缺 model 拒绝", `{"input":"你好"}`, true, ""},
|
||||
{"缺 input 拒绝", `{"model":"xai.grok-tts"}`, true, ""},
|
||||
{"language 缺省注入 auto", `{"model":"xai.grok-tts","input":"你好","voice":"ara"}`, false, "auto"},
|
||||
{"language 已有保留", `{"model":"xai.grok-tts","input":"你好","language":"zh"}`, false, "zh"},
|
||||
{"非 JSON 拒绝", `<html>`, true, ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
modelName, body, err := SpeechBodyNormalize([]byte(tt.raw))
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("err = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(body, &out)
|
||||
if modelName != "xai.grok-tts" || out["language"] != tt.wantLang {
|
||||
t.Fatalf("model=%s language=%v, want %s", modelName, out["language"], tt.wantLang)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestModerationInputs 断言 input 的 string / []string 解析与边界校验。
|
||||
func TestModerationInputs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantN int
|
||||
wantErr bool
|
||||
}{
|
||||
{"单字符串", `"hello"`, 1, false},
|
||||
{"数组", `["a","b"]`, 2, false},
|
||||
{"空字符串拒绝", `""`, 0, true},
|
||||
{"空数组拒绝", `[]`, 0, true},
|
||||
{"含空条目拒绝", `["a",""]`, 0, true},
|
||||
{"超上限拒绝", `["1","2","3","4","5","6","7","8","9"]`, 0, true},
|
||||
{"非法类型拒绝", `{"x":1}`, 0, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ModerationInputs(json.RawMessage(tt.raw))
|
||||
if (err != nil) != tt.wantErr || len(got) != tt.wantN {
|
||||
t.Fatalf("got %v (err=%v), want n=%d wantErr=%v", got, err, tt.wantN, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestModerationResultMapping 断言 guardrails 结果到 OpenAI 外壳的映射与 flagged 判定。
|
||||
func TestModerationResultMapping(t *testing.T) {
|
||||
one := 1.0
|
||||
zero := 0.0
|
||||
tests := []struct {
|
||||
name string
|
||||
outcome oci.GuardrailsOutcome
|
||||
wantFlagged bool
|
||||
wantPii int
|
||||
}{
|
||||
{"内容审核命中", oci.GuardrailsOutcome{Categories: []oci.GuardrailCategory{{Name: "OVERALL", Score: 1}}}, true, 0},
|
||||
{"提示注入命中", oci.GuardrailsOutcome{PromptInjectionScore: &one}, true, 0},
|
||||
{"仅 PII 不 flag", oci.GuardrailsOutcome{Categories: []oci.GuardrailCategory{{Name: "OVERALL", Score: 0}},
|
||||
PromptInjectionScore: &zero, Pii: []oci.GuardrailPiiHit{{Text: "Jane", Label: "PERSON", Score: 0.99}}}, false, 1},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
res := moderationResult(&tt.outcome)
|
||||
if res.Flagged != tt.wantFlagged || len(res.Pii) != tt.wantPii {
|
||||
t.Fatalf("res = %+v, want flagged=%v pii=%d", res, tt.wantFlagged, tt.wantPii)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAiRerank 断言重排编排:能力路由、top_n 透传、return_documents 回填与越界防御。
|
||||
func TestAiRerank(t *testing.T) {
|
||||
client := &gatewayStubClient{
|
||||
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||
rerankRanks: []oci.RerankRank{{Index: 1, Score: 0.9}, {Index: 0, Score: 0.4}, {Index: 9, Score: 0.1}},
|
||||
}
|
||||
gw, svc := newTestGateway(t, client)
|
||||
cfg := importAliveConfig(t, svc)
|
||||
ch := seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1)
|
||||
gw.db.Create(&model.AiModelCache{ChannelID: ch.ID, ModelOcid: "ocid1..rr", Name: "cohere.rerank-v4.0-fast",
|
||||
Vendor: "cohere", Capability: "RERANK", SyncedAt: time.Now()})
|
||||
ctx := context.Background()
|
||||
yes := true
|
||||
req := aiwire.RerankRequest{Model: "cohere.rerank-v4.0-fast", Query: "q", Documents: []string{"d0", "d1"}, ReturnDocuments: &yes}
|
||||
resp, _, err := gw.Rerank(ctx, req, "")
|
||||
if err != nil || len(resp.Results) != 2 {
|
||||
t.Fatalf("Rerank = %+v, %v(越界 index 应被丢弃)", resp, err)
|
||||
}
|
||||
if resp.Results[0].Index != 1 || resp.Results[0].Document == nil || resp.Results[0].Document.Text != "d1" {
|
||||
t.Fatalf("results[0] = %+v", resp.Results[0])
|
||||
}
|
||||
// 对话模型名打 rerank:能力不匹配 → 未知模型
|
||||
if _, _, err := gw.Rerank(ctx, aiwire.RerankRequest{Model: "meta.llama-3.3-70b-instruct", Query: "q", Documents: []string{"d"}}, ""); !errors.Is(err, ErrAiUnknownModel) {
|
||||
t.Errorf("chat 模型走 rerank err = %v, want ErrAiUnknownModel", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAiSpeech 断言 TTS 编排走 TTS 能力路由并透传音频与 Content-Type。
|
||||
func TestAiSpeech(t *testing.T) {
|
||||
client := &gatewayStubClient{
|
||||
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||
speechAudio: []byte{0xFF, 0xF3}, speechCT: "audio/mpeg",
|
||||
}
|
||||
gw, svc := newTestGateway(t, client)
|
||||
cfg := importAliveConfig(t, svc)
|
||||
ch := seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1)
|
||||
gw.db.Create(&model.AiModelCache{ChannelID: ch.ID, ModelOcid: "ocid1..tts", Name: "xai.grok-tts",
|
||||
Vendor: "xai", Capability: "TTS", SyncedAt: time.Now()})
|
||||
audio, ct, _, err := gw.Speech(context.Background(), "xai.grok-tts", []byte(`{"model":"xai.grok-tts","input":"你好","language":"auto"}`), "")
|
||||
if err != nil || ct != "audio/mpeg" || len(audio) != 2 {
|
||||
t.Fatalf("Speech = %d bytes, ct=%s, %v", len(audio), ct, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAiModerations 断言审核编排:无模型维度按分组选渠道,多条输入逐条聚合。
|
||||
func TestAiModerations(t *testing.T) {
|
||||
one := 1.0
|
||||
client := &gatewayStubClient{
|
||||
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||
guardOutcome: &oci.GuardrailsOutcome{Categories: []oci.GuardrailCategory{{Name: "OVERALL", Score: 1}}, PromptInjectionScore: &one},
|
||||
}
|
||||
gw, svc := newTestGateway(t, client)
|
||||
cfg := importAliveConfig(t, svc)
|
||||
seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1)
|
||||
resp, meta, err := gw.Moderations(context.Background(), "modr_test", []string{"a", "b"}, "")
|
||||
if err != nil || len(resp.Results) != 2 || !resp.Results[0].Flagged || resp.ID != "modr_test" {
|
||||
t.Fatalf("Moderations = %+v, meta=%+v, %v", resp, meta, err)
|
||||
}
|
||||
if !strings.Contains(resp.Model, "guardrails") {
|
||||
t.Errorf("model = %s", resp.Model)
|
||||
}
|
||||
// 分组不匹配 → 无可用渠道
|
||||
if _, _, err := gw.Moderations(context.Background(), "modr_x", []string{"a"}, "ghost-group"); !errors.Is(err, ErrAiNoChannel) {
|
||||
t.Errorf("ghost 分组 err = %v, want ErrAiNoChannel", err)
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,26 @@ type gatewayStubClient struct {
|
||||
passErrs []error
|
||||
passCalls int
|
||||
passRegions []string
|
||||
|
||||
speechAudio []byte
|
||||
speechCT string
|
||||
speechErr error
|
||||
rerankRanks []oci.RerankRank
|
||||
rerankErr error
|
||||
guardOutcome *oci.GuardrailsOutcome
|
||||
guardErr error
|
||||
}
|
||||
|
||||
func (f *gatewayStubClient) GenAiCompatSpeech(ctx context.Context, cred oci.Credentials, region string, body []byte) ([]byte, string, error) {
|
||||
return f.speechAudio, f.speechCT, f.speechErr
|
||||
}
|
||||
|
||||
func (f *gatewayStubClient) GenAiRerank(ctx context.Context, cred oci.Credentials, region, modelOcid, query string, documents []string, topN *int) ([]oci.RerankRank, error) {
|
||||
return f.rerankRanks, f.rerankErr
|
||||
}
|
||||
|
||||
func (f *gatewayStubClient) GenAiApplyGuardrails(ctx context.Context, cred oci.Credentials, region, text string) (*oci.GuardrailsOutcome, error) {
|
||||
return f.guardOutcome, f.guardErr
|
||||
}
|
||||
|
||||
func (f *gatewayStubClient) GenAiCompatResponses(ctx context.Context, cred oci.Credentials, region string, body []byte) ([]byte, error) {
|
||||
|
||||
@@ -22,34 +22,21 @@ func respRejectStateful(req aiwire.RespRequest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RespServerTools 报告工具列表是否含 xAI 服务端工具(web_search / x_search)。
|
||||
func RespServerTools(tools []aiwire.RespTool) bool {
|
||||
for _, t := range tools {
|
||||
if t.Type == "web_search" || t.Type == "x_search" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RespPassthroughValidate 校验直通请求:模型必填,有状态特性不支持,工具类型
|
||||
// 只放行 function 与已实测的 web_search / x_search;流式仅在含服务端工具时拒绝
|
||||
// (工具流式事件形态未实测,不放开)。
|
||||
// 只放行 function 与 Oracle 文档化的服务端工具(web_search / x_search /
|
||||
// code_interpreter / mcp)。
|
||||
func RespPassthroughValidate(req aiwire.RespRequest) error {
|
||||
if strings.TrimSpace(req.Model) == "" {
|
||||
return fmt.Errorf("model 不能为空")
|
||||
}
|
||||
if req.Stream && RespServerTools(req.Tools) {
|
||||
return fmt.Errorf("服务端工具暂不支持流式:请去掉 stream 或改用 function 工具")
|
||||
}
|
||||
if err := respRejectStateful(req); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, t := range req.Tools {
|
||||
switch t.Type {
|
||||
case "function", "web_search", "x_search":
|
||||
case "function", "web_search", "x_search", "code_interpreter", "mcp":
|
||||
default:
|
||||
return fmt.Errorf("不支持的工具类型 %q:服务端工具仅支持 web_search / x_search", t.Type)
|
||||
return fmt.Errorf("不支持的工具类型 %q:服务端工具仅支持 web_search / x_search / code_interpreter / mcp", t.Type)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -24,27 +24,6 @@ func deref(p *int) int {
|
||||
return *p
|
||||
}
|
||||
|
||||
func TestRespServerTools(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tools []aiwire.RespTool
|
||||
want bool
|
||||
}{
|
||||
{"web_search", []aiwire.RespTool{{Type: "web_search"}}, true},
|
||||
{"x_search混用", []aiwire.RespTool{{Type: "function", Name: "f"}, {Type: "x_search"}}, true},
|
||||
{"仅function", []aiwire.RespTool{{Type: "function", Name: "f"}}, false},
|
||||
{"空", nil, false},
|
||||
{"其他类型", []aiwire.RespTool{{Type: "code_interpreter"}}, false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := RespServerTools(test.tools); got != test.want {
|
||||
t.Fatalf("got %v, want %v", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRespPassthroughValidate(t *testing.T) {
|
||||
prev := "resp_1"
|
||||
bg := true
|
||||
@@ -56,12 +35,14 @@ func TestRespPassthroughValidate(t *testing.T) {
|
||||
{"合法", aiwire.RespRequest{Model: "m", Tools: []aiwire.RespTool{{Type: "web_search"}}}, false},
|
||||
{"混用function", aiwire.RespRequest{Model: "m", Tools: []aiwire.RespTool{{Type: "web_search"}, {Type: "function", Name: "f"}}}, false},
|
||||
{"缺model", aiwire.RespRequest{Tools: []aiwire.RespTool{{Type: "web_search"}}}, true},
|
||||
{"工具加流式拒绝", aiwire.RespRequest{Model: "m", Stream: true, Tools: []aiwire.RespTool{{Type: "web_search"}}}, true},
|
||||
{"服务端工具流式放行", aiwire.RespRequest{Model: "m", Stream: true, Tools: []aiwire.RespTool{{Type: "web_search"}}}, false},
|
||||
{"无工具流式放行", aiwire.RespRequest{Model: "m", Stream: true}, false},
|
||||
{"function工具流式放行", aiwire.RespRequest{Model: "m", Stream: true, Tools: []aiwire.RespTool{{Type: "function", Name: "f"}}}, false},
|
||||
{"code_interpreter放行", aiwire.RespRequest{Model: "m", Tools: []aiwire.RespTool{{Type: "code_interpreter"}}}, false},
|
||||
{"mcp流式放行", aiwire.RespRequest{Model: "m", Stream: true, Tools: []aiwire.RespTool{{Type: "mcp"}}}, false},
|
||||
{"有状态拒绝", aiwire.RespRequest{Model: "m", PreviousResponseID: prev}, true},
|
||||
{"background拒绝", aiwire.RespRequest{Model: "m", Background: &bg}, true},
|
||||
{"未知工具拒绝", aiwire.RespRequest{Model: "m", Tools: []aiwire.RespTool{{Type: "web_search"}, {Type: "mcp"}}}, true},
|
||||
{"未知工具拒绝", aiwire.RespRequest{Model: "m", Tools: []aiwire.RespTool{{Type: "web_search"}, {Type: "file_search"}}}, true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user