初始提交:OCI 面板后端(含 GenAI 网关一期)
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/common"
|
||||
"github.com/oracle/oci-go-sdk/v65/generativeai"
|
||||
"github.com/oracle/oci-go-sdk/v65/generativeaiinference"
|
||||
|
||||
"oci-portal/internal/aiwire"
|
||||
)
|
||||
|
||||
// GenAiModel 是区域可用基础模型的摘要(管理面 ListModels)。
|
||||
type GenAiModel struct {
|
||||
Ocid string `json:"ocid"`
|
||||
Name string `json:"name"`
|
||||
Vendor string `json:"vendor"`
|
||||
ChatOnly bool `json:"-"`
|
||||
Caps []string `json:"capabilities"`
|
||||
// Capability 是网关侧归一能力:CHAT / EMBEDDING(兼具时算 CHAT)
|
||||
Capability string `json:"capability"`
|
||||
// Deprecated 是 OCI 宣布的弃用时间(TimeDeprecated),nil 表示未宣布;
|
||||
// 弃用后模型仍可调用,直到 Retired(TimeOnDemandRetired 按需推理退役)。
|
||||
Deprecated *time.Time `json:"deprecated"`
|
||||
// Retired 是按需推理退役时间;已过表示调用必 404,同步层直接剔除
|
||||
Retired *time.Time `json:"retired"`
|
||||
}
|
||||
|
||||
// GenAiStream 逐事件读取流式聊天;Next 在流结束时返回 io.EOF。
|
||||
type GenAiStream interface {
|
||||
Next() (aiwire.ChatChunk, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
func (c *RealClient) genAiClient(cred Credentials, region string) (generativeai.GenerativeAiClient, error) {
|
||||
gc, err := generativeai.NewGenerativeAiClientWithConfigurationProvider(provider(cred))
|
||||
if err != nil {
|
||||
return gc, fmt.Errorf("new generative ai client: %w", err)
|
||||
}
|
||||
applyProxy(&gc.BaseClient, cred)
|
||||
if region != "" {
|
||||
gc.SetRegion(normalizeRegion(region))
|
||||
}
|
||||
return gc, nil
|
||||
}
|
||||
|
||||
func (c *RealClient) genAiInferenceClient(cred Credentials, region string) (generativeaiinference.GenerativeAiInferenceClient, error) {
|
||||
ic, err := generativeaiinference.NewGenerativeAiInferenceClientWithConfigurationProvider(provider(cred))
|
||||
if err != nil {
|
||||
return ic, fmt.Errorf("new generative ai inference client: %w", err)
|
||||
}
|
||||
applyProxy(&ic.BaseClient, cred)
|
||||
if region != "" {
|
||||
ic.SetRegion(normalizeRegion(region))
|
||||
}
|
||||
return ic, nil
|
||||
}
|
||||
|
||||
// ListGenAiModels 实现 Client:列出区域的 CHAT / EMBEDDING 能力 ACTIVE 基础模型(去重按名称)。
|
||||
func (c *RealClient) ListGenAiModels(ctx context.Context, cred Credentials, region string) ([]GenAiModel, error) {
|
||||
gc, err := c.genAiClient(cred, region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := gc.ListModels(ctx, generativeai.ListModelsRequest{CompartmentId: &cred.TenancyOCID})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list genai models: %w", err)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
now := time.Now()
|
||||
var out []GenAiModel
|
||||
for _, m := range resp.Items {
|
||||
gm, ok := toGenAiModel(m, now)
|
||||
if !ok || seen[gm.Name] {
|
||||
continue
|
||||
}
|
||||
seen[gm.Name] = true
|
||||
out = append(out, gm)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// toGenAiModel 压平模型摘要;无归一能力、无名称或按需推理已退役
|
||||
// (调用必 404,ListModels 仍会返回且 state 为 ACTIVE)时返回 false 不入池。
|
||||
func toGenAiModel(m generativeai.ModelSummary, now time.Time) (GenAiModel, bool) {
|
||||
capability := modelCapability(m)
|
||||
name := deref(m.DisplayName)
|
||||
if capability == "" || name == "" {
|
||||
return GenAiModel{}, false
|
||||
}
|
||||
if m.TimeOnDemandRetired != nil && m.TimeOnDemandRetired.Time.Before(now) {
|
||||
return GenAiModel{}, false
|
||||
}
|
||||
gm := GenAiModel{Ocid: deref(m.Id), Name: name, Vendor: deref(m.Vendor),
|
||||
Caps: capStrings(m.Capabilities), Capability: capability}
|
||||
if m.TimeDeprecated != nil {
|
||||
gm.Deprecated = &m.TimeDeprecated.Time
|
||||
}
|
||||
if m.TimeOnDemandRetired != nil {
|
||||
gm.Retired = &m.TimeOnDemandRetired.Time
|
||||
}
|
||||
return gm, true
|
||||
}
|
||||
|
||||
// modelCapability 归一模型能力:ACTIVE 且具 CHAT / TEXT_EMBEDDINGS 才纳入(兼具时算 CHAT)。
|
||||
func modelCapability(m generativeai.ModelSummary) string {
|
||||
if m.LifecycleState != generativeai.ModelLifecycleStateActive {
|
||||
return ""
|
||||
}
|
||||
capability := ""
|
||||
for _, cap := range m.Capabilities {
|
||||
switch cap {
|
||||
case generativeai.ModelCapabilityChat:
|
||||
return "CHAT"
|
||||
case generativeai.ModelCapabilityTextEmbeddings:
|
||||
capability = "EMBEDDING"
|
||||
}
|
||||
}
|
||||
return capability
|
||||
}
|
||||
|
||||
func capStrings(caps []generativeai.ModelCapabilityEnum) []string {
|
||||
out := make([]string, 0, len(caps))
|
||||
for _, c := range caps {
|
||||
out = append(out, string(c))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GenAiChat 实现 Client:非流式聊天;modelOcid 走 on-demand serving,
|
||||
// cohere.* 模型走 COHERE 请求格式,其余走 GENERIC。
|
||||
func (c *RealClient) GenAiChat(ctx context.Context, cred Credentials, region, modelOcid string, ir aiwire.ChatRequest) (*aiwire.ChatResponse, error) {
|
||||
ic, err := c.genAiInferenceClient(cred, region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := buildChatRequest(ir, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := ic.Chat(ctx, chatRequest(cred, modelOcid, req))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("genai chat: %w", err)
|
||||
}
|
||||
return chatResponseToIR(resp.ChatResult.ChatResponse, ir.Model)
|
||||
}
|
||||
|
||||
// buildChatRequest 按模型 vendor 组装底层请求体。
|
||||
func buildChatRequest(ir aiwire.ChatRequest, stream bool) (generativeaiinference.BaseChatRequest, error) {
|
||||
if isCohereModel(ir.Model) {
|
||||
return irToCohereSDK(ir, stream)
|
||||
}
|
||||
return irToSDK(ir, stream), nil
|
||||
}
|
||||
|
||||
// chatResponseToIR 按响应实际形态(GENERIC / COHERE)转回 IR。
|
||||
func chatResponseToIR(resp generativeaiinference.BaseChatResponse, model string) (*aiwire.ChatResponse, error) {
|
||||
switch r := resp.(type) {
|
||||
case generativeaiinference.GenericChatResponse:
|
||||
return sdkToIR(r, model), nil
|
||||
case generativeaiinference.CohereChatResponse:
|
||||
return cohereSDKToIR(r, model), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("genai chat: unexpected response format %T", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func chatRequest(cred Credentials, modelOcid string, req generativeaiinference.BaseChatRequest) generativeaiinference.ChatRequest {
|
||||
return generativeaiinference.ChatRequest{
|
||||
ChatDetails: generativeaiinference.ChatDetails{
|
||||
CompartmentId: &cred.TenancyOCID,
|
||||
ServingMode: generativeaiinference.OnDemandServingMode{ModelId: &modelOcid},
|
||||
ChatRequest: req,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GenAiChatStream 实现 Client:流式聊天,返回逐事件读取器。
|
||||
// SDK 对 text/event-stream 跳过 unmarshal,原始流经 RawResponse 交由 SSEReader 消费。
|
||||
func (c *RealClient) GenAiChatStream(ctx context.Context, cred Credentials, region, modelOcid string, ir aiwire.ChatRequest) (GenAiStream, error) {
|
||||
ic, err := c.genAiInferenceClient(cred, region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := buildChatRequest(ir, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := ic.Chat(ctx, chatRequest(cred, modelOcid, req))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("genai chat stream: %w", err)
|
||||
}
|
||||
reader, err := common.NewSSEReader(resp.RawResponse)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("genai chat stream: sse reader: %w", err)
|
||||
}
|
||||
return &genAiSSEStream{reader: reader, body: resp.RawResponse.Body, model: ir.Model}, nil
|
||||
}
|
||||
|
||||
// genAiSSEStream 把 OCI SSE 事件流适配为 IR chunk 流。
|
||||
type genAiSSEStream struct {
|
||||
reader *common.SseReader
|
||||
body io.ReadCloser
|
||||
model string
|
||||
}
|
||||
|
||||
// Next 读取下一条有效事件;空事件与 [DONE] 哨兵跳过,流尽返回 io.EOF。
|
||||
func (s *genAiSSEStream) Next() (aiwire.ChatChunk, error) {
|
||||
for {
|
||||
data, err := s.reader.ReadNextEvent()
|
||||
if err != nil {
|
||||
return aiwire.ChatChunk{}, err
|
||||
}
|
||||
text := strings.TrimSpace(string(data))
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
if text == "[DONE]" {
|
||||
return aiwire.ChatChunk{}, io.EOF
|
||||
}
|
||||
if chunk, ok := parseGenAiEvent([]byte(text), s.model); ok {
|
||||
return chunk, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *genAiSSEStream) Close() error {
|
||||
if s.body != nil {
|
||||
return s.body.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenAiProbeChat 实现 Client:配额探测专用的最小聊天(maxTokens=1),返回 HTTP 状态码;
|
||||
// modelName 决定请求格式(cohere.* 走 COHERE)。
|
||||
func (c *RealClient) GenAiProbeChat(ctx context.Context, cred Credentials, region, modelOcid, modelName string) (int, error) {
|
||||
one := 1
|
||||
ir := aiwire.ChatRequest{
|
||||
Model: modelName,
|
||||
Messages: []aiwire.ChatMessage{{Role: "user", Content: aiwire.NewTextContent("hi")}},
|
||||
MaxTokens: &one,
|
||||
}
|
||||
_, err := c.GenAiChat(ctx, cred, region, modelOcid, ir)
|
||||
if err == nil {
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
if status, ok := ServiceStatus(err); ok {
|
||||
return status, err
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// GenAiEmbed 实现 Client:文本向量化(on-demand serving);dimensions 透传 OutputDimensions。
|
||||
func (c *RealClient) GenAiEmbed(ctx context.Context, cred Credentials, region, modelOcid string, inputs []string, dimensions *int) ([][]float32, *aiwire.Usage, error) {
|
||||
ic, err := c.genAiInferenceClient(cred, region)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
resp, err := ic.EmbedText(ctx, generativeaiinference.EmbedTextRequest{
|
||||
EmbedTextDetails: generativeaiinference.EmbedTextDetails{
|
||||
CompartmentId: &cred.TenancyOCID,
|
||||
ServingMode: generativeaiinference.OnDemandServingMode{ModelId: &modelOcid},
|
||||
Inputs: inputs,
|
||||
OutputDimensions: dimensions,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("genai embed: %w", err)
|
||||
}
|
||||
return resp.Embeddings, sdkUsageToIR(resp.Usage), nil
|
||||
}
|
||||
Reference in New Issue
Block a user