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"` 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) } return dedupGenAiModels(resp.Items, time.Now()), nil } // dedupGenAiModels 压平并按名称去重;同名多条目时优先保留不含 FINE_TUNE 能力的条目 // (微调基座条目在部分区域不支持按需调用,缓存其 OCID 会导致调用 400)。 func dedupGenAiModels(items []generativeai.ModelSummary, now time.Time) []GenAiModel { seen := map[string]int{} var out []GenAiModel for _, m := range items { gm, ok := toGenAiModel(m, now) if !ok { continue } if i, dup := seen[gm.Name]; dup { if hasFineTune(out[i].Caps) && !hasFineTune(gm.Caps) { out[i] = gm } continue } seen[gm.Name] = len(out) out = append(out, gm) } return out } // hasFineTune 判断能力列表是否含 FINE_TUNE(微调基座条目)。 func hasFineTune(caps []string) bool { for _, c := range caps { if c == string(generativeai.ModelCapabilityFineTune) { return true } } return false } // 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 }