497 lines
17 KiB
Go
497 lines
17 KiB
Go
package oci
|
|
|
|
// 真实 OCI GenAI effort 探针(重建版)。
|
|
//
|
|
// 前身 genai_cache_integration_test.go 承载缓存/亲和/服务端工具等历轮调研模式,
|
|
// 调研结论均已归档(.trellis/tasks/archive/2026-07/*/research/),该文件已删除;
|
|
// 本文件重建共享基础设施,保留 effort 支持矩阵探测,并新增 responses 端点探测
|
|
// (multi-agent 模型仅允许走 Responses 面)。
|
|
//
|
|
// 运行(默认跳过,不触网):
|
|
//
|
|
// OCI_GENAI_CACHE_PROBE=effort-matrix OCI_CACHE_DB=<db 绝对路径> DATA_KEY=<主密钥> \
|
|
// OCI_CACHE_RUN_ID=<唯一标识> go test ./internal/oci/ -run TestRealGenAiEffortMatrix -v
|
|
//
|
|
// 可选:OCI_CACHE_CONFIG_ID(默认 6)、OCI_CACHE_REGION(默认 us-chicago-1)、
|
|
// OCI_EFFORT_CASES 自定义用例("model=effort" 逗号分隔;effort 后缀 "@responses"
|
|
// 表示走 /actions/v1/responses 端点,如 "xai.grok-4.20-multi-agent=low@responses")。
|
|
//
|
|
// 纪律:数据库只读打开;OCI_GO_SDK_DEBUG 必须为空;日志不落原始请求体与凭据,
|
|
// 错误消息经 OCID 遮掩后截断。
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/glebarez/sqlite"
|
|
"github.com/oracle/oci-go-sdk/v65/common"
|
|
"github.com/oracle/oci-go-sdk/v65/generativeaiinference"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
|
|
projectcrypto "oci-portal/internal/crypto"
|
|
"oci-portal/internal/model"
|
|
)
|
|
|
|
const (
|
|
effortProbeRegion = "us-chicago-1"
|
|
effortProbeLimit = int64(4 << 20)
|
|
)
|
|
|
|
var effortRunIDPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`)
|
|
|
|
type effortProbeCase struct {
|
|
model string
|
|
effort string
|
|
responses bool // true 走 /actions/v1/responses,否则 typed /actions/chat
|
|
stream bool // 仅 responses 面:请求 SSE 流,观测事件类型序列
|
|
}
|
|
|
|
type effortProbeResources struct {
|
|
cred Credentials
|
|
region string
|
|
modelOCID map[string]string
|
|
inference generativeaiinference.GenerativeAiInferenceClient
|
|
}
|
|
|
|
type effortProbeResult struct {
|
|
status int
|
|
requestRef string
|
|
duration time.Duration
|
|
errorCode string
|
|
errorMessage string
|
|
completionTokens int
|
|
reasoningTokens int
|
|
answerLen int
|
|
outputTypes []string
|
|
}
|
|
|
|
// effortMatrixCases 默认批为 grok-4.3 五档基线;全模型矩阵结论见任务归档,
|
|
// 复测用 OCI_EFFORT_CASES 自定义。
|
|
func effortMatrixCases() []effortProbeCase {
|
|
if raw := os.Getenv("OCI_EFFORT_CASES"); raw != "" {
|
|
return parseEffortCases(raw)
|
|
}
|
|
return []effortProbeCase{
|
|
{model: "xai.grok-4.3", effort: "none", responses: true},
|
|
{model: "xai.grok-4.3", effort: "low", responses: true},
|
|
{model: "xai.grok-4.3", effort: "high", responses: true},
|
|
}
|
|
}
|
|
|
|
// parseEffortCases 解析 "model=effort[@responses|@responses-stream]" 逗号分隔用例。
|
|
func parseEffortCases(raw string) []effortProbeCase {
|
|
var out []effortProbeCase
|
|
for _, item := range strings.Split(raw, ",") {
|
|
parts := strings.SplitN(strings.TrimSpace(item), "=", 2)
|
|
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
|
continue
|
|
}
|
|
effort, streaming := strings.CutSuffix(parts[1], "@responses-stream")
|
|
if streaming {
|
|
out = append(out, effortProbeCase{model: parts[0], effort: effort, responses: true, stream: true})
|
|
continue
|
|
}
|
|
effort, _ = strings.CutSuffix(effort, "@responses") // 后缀兼容保留;所有用例均走 responses 面
|
|
out = append(out, effortProbeCase{model: parts[0], effort: effort, responses: true})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestRealGenAiEffortMatrix(t *testing.T) {
|
|
requireEffortProbeMode(t, "effort-matrix")
|
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
|
|
defer cancel()
|
|
resources := newEffortProbeResources(t, ctx)
|
|
for _, tc := range effortMatrixCases() {
|
|
runEffortCase(t, ctx, resources, tc)
|
|
}
|
|
}
|
|
|
|
func requireEffortProbeMode(t *testing.T, wanted string) {
|
|
t.Helper()
|
|
if os.Getenv("OCI_GENAI_CACHE_PROBE") != wanted {
|
|
t.Skipf("set OCI_GENAI_CACHE_PROBE=%s to run", wanted)
|
|
}
|
|
if os.Getenv("OCI_GO_SDK_DEBUG") != "" {
|
|
t.Fatal("OCI_GO_SDK_DEBUG must be unset to protect signed requests")
|
|
}
|
|
if !effortRunIDPattern.MatchString(os.Getenv("OCI_CACHE_RUN_ID")) {
|
|
t.Fatal("set a unique OCI_CACHE_RUN_ID (1-64 safe characters)")
|
|
}
|
|
}
|
|
|
|
func runEffortCase(t *testing.T, ctx context.Context, resources effortProbeResources, tc effortProbeCase) {
|
|
t.Helper()
|
|
ocid, ok := resources.modelOCID[tc.model]
|
|
if !ok {
|
|
t.Logf("model=%s effort=%s resolve-failed: model not in on-demand catalog", tc.model, tc.effort)
|
|
return
|
|
}
|
|
_ = ocid
|
|
body, path, err := effortProbeBody(tc)
|
|
if err != nil {
|
|
t.Fatalf("build effort body: %v", err)
|
|
}
|
|
result := executeEffortProbe(ctx, resources, path, body)
|
|
logEffortResult(t, tc, result)
|
|
}
|
|
|
|
// effortProbeBody 构造 OpenAI Responses 直通请求体(typed chat 面已随网关剔除,
|
|
// 探针仅保留 responses 端点;历史 typed 结论见任务归档)。
|
|
func effortProbeBody(tc effortProbeCase) ([]byte, string, error) {
|
|
question := "How many positive divisors does 360 have? Answer with the number only."
|
|
body := map[string]interface{}{"model": tc.model, "input": question,
|
|
"max_output_tokens": 2048, "store": false}
|
|
if tc.effort != "" {
|
|
body["reasoning"] = map[string]string{"effort": tc.effort}
|
|
}
|
|
if tc.stream {
|
|
body["stream"] = true
|
|
}
|
|
payload, err := json.Marshal(body)
|
|
return payload, "/actions/v1/responses", err
|
|
}
|
|
|
|
// executeEffortProbe 以 IAM 签名裸 POST 指定路径;responses 面按生产实现补 compartment 头。
|
|
func executeEffortProbe(ctx context.Context, resources effortProbeResources, path string, body []byte) effortProbeResult {
|
|
client := resources.inference.BaseClient
|
|
client.Configuration.CircuitBreaker = nil
|
|
common.UpdateEndpointTemplateForOptions(&client)
|
|
common.SetMissingTemplateParams(&client)
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, path, strings.NewReader(string(body)))
|
|
if err != nil {
|
|
return effortProbeResult{errorCode: "BuildRequest", errorMessage: err.Error()}
|
|
}
|
|
request.Header.Set("Content-Type", "application/json")
|
|
if path != "/actions/chat" {
|
|
request.Header.Set("CompartmentId", resources.cred.TenancyOCID)
|
|
request.Header.Set("opc-compartment-id", resources.cred.TenancyOCID)
|
|
}
|
|
return effortCallProbe(ctx, client, request)
|
|
}
|
|
|
|
func effortCallProbe(ctx context.Context, client common.BaseClient, request *http.Request) effortProbeResult {
|
|
started := time.Now()
|
|
response, err := client.Call(ctx, request)
|
|
result := effortProbeResult{duration: time.Since(started)}
|
|
if response != nil {
|
|
result.status = response.StatusCode
|
|
result.requestRef = shortEffortRef(response.Header.Get("opc-request-id"))
|
|
defer response.Body.Close()
|
|
}
|
|
if err != nil {
|
|
result.errorCode, result.errorMessage = effortProbeError(err)
|
|
return result
|
|
}
|
|
payload, readErr := io.ReadAll(io.LimitReader(response.Body, effortProbeLimit))
|
|
if readErr != nil {
|
|
result.errorCode, result.errorMessage = "ReadResponse", readErr.Error()
|
|
return result
|
|
}
|
|
parseEffortPayload(&result, payload)
|
|
return result
|
|
}
|
|
|
|
func effortProbeError(err error) (string, string) {
|
|
if serviceErr, ok := common.IsServiceError(err); ok {
|
|
return fmt.Sprintf("%d", serviceErr.GetHTTPStatusCode()), sanitizeEffortText(serviceErr.GetMessage())
|
|
}
|
|
return "CallError", sanitizeEffortText(err.Error())
|
|
}
|
|
|
|
// parseEffortPayload 兼容三种 usage 形态:typed 驼峰(chatResponse.usage.completionTokens)、
|
|
// chat 兼容面下划线(usage.completion_tokens)、responses 面(usage.output_tokens)。
|
|
func parseEffortPayload(result *effortProbeResult, payload []byte) {
|
|
if text := strings.TrimSpace(string(payload)); strings.HasPrefix(text, "event:") || strings.HasPrefix(text, "data:") {
|
|
parseEffortSSE(result, text)
|
|
return
|
|
}
|
|
var root map[string]interface{}
|
|
if json.Unmarshal(payload, &root) != nil {
|
|
return
|
|
}
|
|
result.answerLen = len(parseEffortAnswer(root))
|
|
result.outputTypes = parseEffortOutputTypes(root)
|
|
if typed, ok := root["chatResponse"].(map[string]interface{}); ok {
|
|
root = typed
|
|
}
|
|
usage, _ := root["usage"].(map[string]interface{})
|
|
for _, key := range []string{"completionTokens", "completion_tokens", "output_tokens"} {
|
|
if v, ok := effortInt(usage, key); ok {
|
|
result.completionTokens = v
|
|
break
|
|
}
|
|
}
|
|
for _, detailsKey := range []string{"completionTokensDetails", "completion_tokens_details", "output_tokens_details"} {
|
|
details, _ := usage[detailsKey].(map[string]interface{})
|
|
for _, key := range []string{"reasoningTokens", "reasoning_tokens"} {
|
|
if v, ok := effortInt(details, key); ok {
|
|
result.reasoningTokens = v
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// parseEffortAnswer 提取正文文本:typed 取 chatResponse.choices[].message.content[].text,
|
|
// responses 取 output[] 里 message 项的 content[].text。仅在内存中量长度,不落日志。
|
|
func parseEffortAnswer(root map[string]interface{}) string {
|
|
var sb strings.Builder
|
|
if typed, ok := root["chatResponse"].(map[string]interface{}); ok {
|
|
choices, _ := typed["choices"].([]interface{})
|
|
for _, c := range choices {
|
|
cm, _ := c.(map[string]interface{})
|
|
msg, _ := cm["message"].(map[string]interface{})
|
|
collectEffortText(&sb, msg["content"])
|
|
}
|
|
return sb.String()
|
|
}
|
|
output, _ := root["output"].([]interface{})
|
|
for _, item := range output {
|
|
im, _ := item.(map[string]interface{})
|
|
if im["type"] == "message" {
|
|
collectEffortText(&sb, im["content"])
|
|
}
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
func collectEffortText(sb *strings.Builder, content interface{}) {
|
|
parts, _ := content.([]interface{})
|
|
for _, p := range parts {
|
|
pm, _ := p.(map[string]interface{})
|
|
if text, ok := pm["text"].(string); ok {
|
|
sb.WriteString(text)
|
|
}
|
|
}
|
|
}
|
|
|
|
// parseEffortSSE 汇总 SSE 流:事件类型去重序列进 outputTypes,正文增量长度进 answerLen,
|
|
// 末尾 completed 事件里的 usage 进 tokens 字段。
|
|
func parseEffortSSE(result *effortProbeResult, text string) {
|
|
seen := map[string]bool{}
|
|
for _, line := range strings.Split(text, "\n") {
|
|
if name, ok := strings.CutPrefix(line, "event: "); ok {
|
|
if name = strings.TrimSpace(name); !seen[name] {
|
|
seen[name] = true
|
|
result.outputTypes = append(result.outputTypes, name)
|
|
}
|
|
continue
|
|
}
|
|
data, ok := strings.CutPrefix(line, "data: ")
|
|
if !ok {
|
|
continue
|
|
}
|
|
var ev map[string]interface{}
|
|
if json.Unmarshal([]byte(data), &ev) != nil {
|
|
continue
|
|
}
|
|
if name, ok := ev["type"].(string); ok && !seen[name] { // 纯 data: 行流(无 event: 行)从事件体取类型
|
|
seen[name] = true
|
|
result.outputTypes = append(result.outputTypes, name)
|
|
}
|
|
if delta, ok := ev["delta"].(string); ok && strings.Contains(fmt.Sprint(ev["type"]), "output_text") {
|
|
result.answerLen += len(delta)
|
|
}
|
|
if resp, ok := ev["response"].(map[string]interface{}); ok {
|
|
usage, _ := resp["usage"].(map[string]interface{})
|
|
if v, ok := effortInt(usage, "output_tokens"); ok {
|
|
result.completionTokens = v
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// parseEffortOutputTypes 收集 responses 面 output 项类型序列(multi-agent 行为观测)。
|
|
func parseEffortOutputTypes(root map[string]interface{}) []string {
|
|
output, _ := root["output"].([]interface{})
|
|
var types []string
|
|
for _, item := range output {
|
|
im, _ := item.(map[string]interface{})
|
|
if s, ok := im["type"].(string); ok {
|
|
types = append(types, s)
|
|
}
|
|
}
|
|
return types
|
|
}
|
|
|
|
func effortInt(root map[string]interface{}, key string) (int, bool) {
|
|
if root == nil {
|
|
return 0, false
|
|
}
|
|
if value, ok := root[key].(float64); ok {
|
|
return int(value), true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func logEffortResult(t *testing.T, tc effortProbeCase, result effortProbeResult) {
|
|
t.Helper()
|
|
endpoint := "typed-chat"
|
|
if tc.responses {
|
|
endpoint = "compat-responses"
|
|
}
|
|
t.Logf("endpoint=%s model=%s effort=%s status=%d completion=%d reasoning=%d answer_len=%d output_types=%v request=%s duration_ms=%d error=%s message=%q",
|
|
endpoint, tc.model, tc.effort, result.status, result.completionTokens, result.reasoningTokens,
|
|
result.answerLen, result.outputTypes, result.requestRef, result.duration.Milliseconds(),
|
|
result.errorCode, result.errorMessage)
|
|
}
|
|
|
|
// sanitizeEffortText 遮掩 OCID 并截断,避免错误消息携带租户可定位信息。
|
|
func sanitizeEffortText(text string) string {
|
|
text = regexp.MustCompile(`ocid1\.[a-z0-9._-]+`).ReplaceAllString(text, "ocid1.***")
|
|
if len(text) > 300 {
|
|
text = text[:300] + "..."
|
|
}
|
|
return text
|
|
}
|
|
|
|
func shortEffortRef(ref string) string {
|
|
if len(ref) > 12 {
|
|
return ref[:12]
|
|
}
|
|
return ref
|
|
}
|
|
|
|
func effortEnvOr(key, fallback string) string {
|
|
if value := os.Getenv(key); value != "" {
|
|
return value
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// newEffortProbeResources 装配凭据、区域、inference 客户端与按需模型目录。
|
|
func newEffortProbeResources(t *testing.T, ctx context.Context) effortProbeResources {
|
|
t.Helper()
|
|
cred := effortProbeCredentials(t)
|
|
region := effortEnvOr("OCI_CACHE_REGION", effortProbeRegion)
|
|
cred.Region = region
|
|
real := &RealClient{}
|
|
ic, err := real.genAiInferenceClient(cred, region)
|
|
if err != nil {
|
|
t.Fatalf("new inference client: %v", err)
|
|
}
|
|
models, err := real.ListGenAiModels(ctx, cred, region)
|
|
if err != nil {
|
|
t.Fatalf("list models: %s", sanitizeEffortText(err.Error()))
|
|
}
|
|
index := make(map[string]string, len(models))
|
|
for _, m := range models {
|
|
index[m.Name] = m.Ocid
|
|
}
|
|
return effortProbeResources{cred: cred, region: region, modelOCID: index, inference: ic}
|
|
}
|
|
|
|
// effortProbeCredentials 优先从面板数据库(只读)取渠道凭据,否则退回本地 ini 测试凭据。
|
|
func effortProbeCredentials(t *testing.T) Credentials {
|
|
t.Helper()
|
|
if dbPath := os.Getenv("OCI_CACHE_DB"); dbPath != "" {
|
|
return effortDBCredentials(t, dbPath)
|
|
}
|
|
return loadTestCredentials(t, effortEnvOr("OCI_TEST_KEY", "试用期"))
|
|
}
|
|
|
|
func effortDBCredentials(t *testing.T, dbPath string) Credentials {
|
|
t.Helper()
|
|
dsn := fmt.Sprintf("file:%s?mode=ro", dbPath)
|
|
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
|
if err != nil {
|
|
t.Fatalf("open read-only probe database: %v", err)
|
|
}
|
|
cipher, err := projectcrypto.NewCipher(os.Getenv("DATA_KEY"))
|
|
if err != nil {
|
|
t.Fatalf("create data cipher: %v", err)
|
|
}
|
|
var config model.OciConfig
|
|
if err := db.First(&config, effortEnvOr("OCI_CACHE_CONFIG_ID", "6")).Error; err != nil {
|
|
t.Fatalf("load OCI config: %v", err)
|
|
}
|
|
return decryptEffortCredentials(t, db, cipher, config)
|
|
}
|
|
|
|
func decryptEffortCredentials(t *testing.T, db *gorm.DB, cipher *projectcrypto.Cipher, config model.OciConfig) Credentials {
|
|
t.Helper()
|
|
privateKey, err := cipher.DecryptString(config.PrivateKeyEnc)
|
|
if err != nil {
|
|
t.Fatalf("decrypt OCI config %d private key: %v", config.ID, err)
|
|
}
|
|
passphrase := ""
|
|
if config.PassphraseEnc != "" {
|
|
if passphrase, err = cipher.DecryptString(config.PassphraseEnc); err != nil {
|
|
t.Fatalf("decrypt OCI config %d passphrase: %v", config.ID, err)
|
|
}
|
|
}
|
|
return Credentials{TenancyOCID: config.TenancyOCID, UserOCID: config.UserOCID,
|
|
Fingerprint: config.Fingerprint, Region: config.Region, PrivateKey: privateKey,
|
|
Passphrase: passphrase, Proxy: loadEffortProxy(t, db, cipher, config.ProxyID)}
|
|
}
|
|
|
|
func loadEffortProxy(t *testing.T, db *gorm.DB, cipher *projectcrypto.Cipher, proxyID *uint) *ProxySpec {
|
|
t.Helper()
|
|
if proxyID == nil {
|
|
return nil
|
|
}
|
|
var proxy model.Proxy
|
|
if err := db.First(&proxy, *proxyID).Error; err != nil {
|
|
t.Fatalf("load proxy %d: %v", *proxyID, err)
|
|
}
|
|
password := ""
|
|
if proxy.PasswordEnc != "" {
|
|
decrypted, err := cipher.DecryptString(proxy.PasswordEnc)
|
|
if err != nil {
|
|
t.Fatalf("decrypt proxy %d password: %v", *proxyID, err)
|
|
}
|
|
password = decrypted
|
|
}
|
|
return &ProxySpec{Type: proxy.Type, Host: proxy.Host, Port: proxy.Port,
|
|
Username: proxy.Username, Password: password}
|
|
}
|
|
|
|
// TestParseEffortCases 断言自定义用例解析:端点后缀、空项与畸形项跳过。
|
|
func TestParseEffortCases(t *testing.T) {
|
|
got := parseEffortCases("a=low, b=high@responses ,bad,=x,c=")
|
|
want := []effortProbeCase{{model: "a", effort: "low", responses: true}, {model: "b", effort: "high", responses: true}}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("parseEffortCases = %+v, want %+v", got, want)
|
|
}
|
|
for i := range want {
|
|
if got[i] != want[i] {
|
|
t.Fatalf("case %d = %+v, want %+v", i, got[i], want[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestParseEffortPayload 断言三种 usage 形态与 output 类型序列的解析。
|
|
func TestParseEffortPayload(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
payload string
|
|
completion int
|
|
reasoning int
|
|
types int
|
|
}{
|
|
{"typed 驼峰", `{"chatResponse":{"choices":[{"message":{"content":[{"type":"TEXT","text":"24"}]}}],"usage":{"completionTokens":5,"completionTokensDetails":{"reasoningTokens":9}}}}`, 5, 9, 0},
|
|
{"responses 面", `{"output":[{"type":"reasoning"},{"type":"message","content":[{"type":"output_text","text":"24"}]}],"usage":{"output_tokens":7,"output_tokens_details":{"reasoning_tokens":3}}}`, 7, 3, 2},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var result effortProbeResult
|
|
parseEffortPayload(&result, []byte(tc.payload))
|
|
if result.completionTokens != tc.completion || result.reasoningTokens != tc.reasoning || len(result.outputTypes) != tc.types {
|
|
t.Fatalf("parse = %+v, want completion=%d reasoning=%d types=%d", result, tc.completion, tc.reasoning, tc.types)
|
|
}
|
|
if result.answerLen != 2 {
|
|
t.Fatalf("answerLen = %d, want 2", result.answerLen)
|
|
}
|
|
})
|
|
}
|
|
}
|