OpenAI-compatible AI gateway with Vue admin UI, multi-provider egress, ingress key governance, monitoring, and security controls. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/rose_cat707/luminary/internal/model"
|
||||
"github.com/rose_cat707/luminary/internal/provider"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
http *provider.HTTPClientWrapper
|
||||
}
|
||||
|
||||
func New() *Client {
|
||||
return &Client{http: provider.NewHTTPClientWrapper()}
|
||||
}
|
||||
|
||||
func (c *Client) Type() string { return "anthropic" }
|
||||
|
||||
func (c *Client) ListModels(ctx context.Context, apiKey, baseURL string) ([]provider.ModelInfo, error) {
|
||||
return c.ListModelsWithConfig(ctx, apiKey, provider.ProviderConfig{
|
||||
Type: model.ProviderAnthropic,
|
||||
BaseURL: provider.NormalizeBaseURL(baseURL, model.ProviderAnthropic),
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) ListModelsWithConfig(ctx context.Context, apiKey string, cfg provider.ProviderConfig) ([]provider.ModelInfo, error) {
|
||||
cfg.Type = model.ProviderAnthropic
|
||||
cfg.BaseURL = provider.NormalizeBaseURL(cfg.BaseURL, model.ProviderAnthropic)
|
||||
url := cfg.ResolveEndpointURL(provider.EndpointListModels)
|
||||
resp, err := provider.ForwardHTTP(ctx, c.http.Client, "GET", url, apiKey, model.ProviderAnthropic, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("anthropic list models: status %d: %s", resp.StatusCode, string(resp.Body))
|
||||
}
|
||||
var out struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"display_name"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Body, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
models := make([]provider.ModelInfo, 0, len(out.Data))
|
||||
for _, m := range out.Data {
|
||||
name := m.Name
|
||||
if name == "" {
|
||||
name = m.ID
|
||||
}
|
||||
models = append(models, provider.ModelInfo{ID: m.ID, DisplayName: name})
|
||||
}
|
||||
return models, nil
|
||||
}
|
||||
|
||||
func (c *Client) ChatCompletion(ctx context.Context, apiKey, baseURL string, req *provider.ChatRequest) (*provider.ChatResponse, error) {
|
||||
cfg := provider.ProviderConfig{
|
||||
Type: model.ProviderAnthropic,
|
||||
BaseURL: provider.NormalizeBaseURL(baseURL, model.ProviderAnthropic),
|
||||
}
|
||||
anthropicReq, err := convertOpenAIToAnthropic(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := json.Marshal(anthropicReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
url := cfg.ResolveEndpointURL(provider.EndpointChatCompletion)
|
||||
resp, err := provider.ForwardHTTP(ctx, c.http.Client, "POST", url, apiKey, model.ProviderAnthropic, body, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return &provider.ChatResponse{StatusCode: resp.StatusCode, Body: resp.Body}, nil
|
||||
}
|
||||
openAIResp, tokensIn, tokensOut := convertAnthropicToOpenAI(resp.Body, req.Model)
|
||||
return &provider.ChatResponse{
|
||||
StatusCode: 200,
|
||||
Body: openAIResp,
|
||||
TokensIn: tokensIn,
|
||||
TokensOut: tokensOut,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) ForwardWithConfig(ctx context.Context, apiKey string, cfg provider.ProviderConfig, endpoint provider.EndpointType, body []byte) (*provider.ChatResponse, error) {
|
||||
cfg.Type = model.ProviderAnthropic
|
||||
cfg.BaseURL = provider.NormalizeBaseURL(cfg.BaseURL, model.ProviderAnthropic)
|
||||
if endpoint == provider.EndpointChatCompletion {
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(body, &payload); err == nil {
|
||||
modelName, _ := payload["model"].(string)
|
||||
anthropicReq, err := convertOpenAIToAnthropic(&provider.ChatRequest{Model: modelName, Raw: body})
|
||||
if err == nil {
|
||||
body, _ = json.Marshal(anthropicReq)
|
||||
}
|
||||
}
|
||||
}
|
||||
url := cfg.ResolveEndpointURL(endpoint)
|
||||
resp, err := provider.ForwardHTTP(ctx, c.http.Client, "POST", url, apiKey, model.ProviderAnthropic, body, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if endpoint == provider.EndpointChatCompletion && resp.StatusCode < 400 {
|
||||
var payload map[string]any
|
||||
modelName := ""
|
||||
_ = json.Unmarshal(body, &payload)
|
||||
if m, ok := payload["model"].(string); ok {
|
||||
modelName = m
|
||||
}
|
||||
openAIResp, tokensIn, tokensOut := convertAnthropicToOpenAI(resp.Body, modelName)
|
||||
return &provider.ChatResponse{StatusCode: 200, Body: openAIResp, TokensIn: tokensIn, TokensOut: tokensOut}, nil
|
||||
}
|
||||
return &provider.ChatResponse{StatusCode: resp.StatusCode, Body: resp.Body, TokensIn: resp.TokensIn, TokensOut: resp.TokensOut}, nil
|
||||
}
|
||||
|
||||
func (c *Client) HealthCheck(ctx context.Context, apiKey, baseURL string) error {
|
||||
_, err := c.ListModels(ctx, apiKey, baseURL)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) HealthCheckWithConfig(ctx context.Context, apiKey string, cfg provider.ProviderConfig) error {
|
||||
_, err := c.ListModelsWithConfig(ctx, apiKey, cfg)
|
||||
return err
|
||||
}
|
||||
|
||||
// convertOpenAIToAnthropic and convertAnthropicToOpenAI unchanged below
|
||||
|
||||
func convertOpenAIToAnthropic(req *provider.ChatRequest) (map[string]any, error) {
|
||||
var payload map[string]any
|
||||
raw := req.Raw
|
||||
if len(raw) == 0 {
|
||||
b, _ := json.Marshal(req)
|
||||
raw = b
|
||||
}
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
model, _ := payload["model"].(string)
|
||||
if model == "" {
|
||||
model = req.Model
|
||||
}
|
||||
msgs, _ := payload["messages"].([]any)
|
||||
var system string
|
||||
var anthropicMsgs []map[string]any
|
||||
for _, m := range msgs {
|
||||
msg, _ := m.(map[string]any)
|
||||
role, _ := msg["role"].(string)
|
||||
if role == "system" {
|
||||
system = extractOpenAIContent(msg["content"])
|
||||
continue
|
||||
}
|
||||
if role != "assistant" {
|
||||
role = "user"
|
||||
}
|
||||
content := convertOpenAIContentBlocks(msg["content"])
|
||||
anthropicMsgs = append(anthropicMsgs, map[string]any{"role": role, "content": content})
|
||||
}
|
||||
out := map[string]any{
|
||||
"model": model,
|
||||
"max_tokens": 4096,
|
||||
"messages": anthropicMsgs,
|
||||
}
|
||||
if system != "" {
|
||||
out["system"] = system
|
||||
}
|
||||
if maxTok, ok := payload["max_tokens"].(float64); ok {
|
||||
out["max_tokens"] = int(maxTok)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func extractOpenAIContent(content any) string {
|
||||
switch v := content.(type) {
|
||||
case string:
|
||||
return v
|
||||
case []any:
|
||||
var parts []string
|
||||
for _, p := range v {
|
||||
part, ok := p.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if text, ok := part["text"].(string); ok {
|
||||
parts = append(parts, text)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func convertOpenAIContentBlocks(content any) any {
|
||||
switch v := content.(type) {
|
||||
case string:
|
||||
if v == "" {
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
case []any:
|
||||
blocks := make([]map[string]any, 0, len(v))
|
||||
for _, p := range v {
|
||||
part, ok := p.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
partType, _ := part["type"].(string)
|
||||
switch partType {
|
||||
case "text":
|
||||
if text, ok := part["text"].(string); ok {
|
||||
blocks = append(blocks, map[string]any{"type": "text", "text": text})
|
||||
}
|
||||
case "image_url":
|
||||
if imageURL, ok := part["image_url"].(map[string]any); ok {
|
||||
if url, ok := imageURL["url"].(string); ok && url != "" {
|
||||
blocks = append(blocks, map[string]any{
|
||||
"type": "image",
|
||||
"source": map[string]any{
|
||||
"type": "url",
|
||||
"url": url,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
case "image":
|
||||
if source, ok := part["source"].(map[string]any); ok {
|
||||
blocks = append(blocks, map[string]any{"type": "image", "source": source})
|
||||
}
|
||||
default:
|
||||
if text, ok := part["text"].(string); ok {
|
||||
blocks = append(blocks, map[string]any{"type": "text", "text": text})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(blocks) == 1 {
|
||||
if text, ok := blocks[0]["text"].(string); ok && blocks[0]["type"] == "text" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
if len(blocks) > 0 {
|
||||
return blocks
|
||||
}
|
||||
return ""
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func convertAnthropicToOpenAI(body []byte, model string) ([]byte, int, int) {
|
||||
var resp struct {
|
||||
Content []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &resp)
|
||||
text := ""
|
||||
if len(resp.Content) > 0 {
|
||||
text = resp.Content[0].Text
|
||||
}
|
||||
openAI := map[string]any{
|
||||
"id": "chatcmpl-luminary",
|
||||
"object": "chat.completion",
|
||||
"model": model,
|
||||
"choices": []any{map[string]any{"index": 0, "message": map[string]string{"role": "assistant", "content": text}, "finish_reason": "stop"}},
|
||||
"usage": map[string]int{
|
||||
"prompt_tokens": resp.Usage.InputTokens,
|
||||
"completion_tokens": resp.Usage.OutputTokens,
|
||||
"total_tokens": resp.Usage.InputTokens + resp.Usage.OutputTokens,
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(openAI)
|
||||
return b, resp.Usage.InputTokens, resp.Usage.OutputTokens
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package anthropic
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestConvertOpenAIContentBlocks_TextAndImage(t *testing.T) {
|
||||
content := []any{
|
||||
map[string]any{"type": "text", "text": "describe this"},
|
||||
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "https://example.com/a.png"}},
|
||||
}
|
||||
blocks, ok := convertOpenAIContentBlocks(content).([]map[string]any)
|
||||
if !ok || len(blocks) != 2 {
|
||||
t.Fatalf("expected 2 blocks, got %#v", convertOpenAIContentBlocks(content))
|
||||
}
|
||||
if blocks[0]["text"] != "describe this" {
|
||||
t.Fatalf("unexpected text block: %#v", blocks[0])
|
||||
}
|
||||
if blocks[1]["type"] != "image" {
|
||||
t.Fatalf("unexpected image block: %#v", blocks[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractOpenAIContent_String(t *testing.T) {
|
||||
if got := extractOpenAIContent("hello"); got != "hello" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package comfyui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
func New(baseURL string) *Client {
|
||||
return &Client{
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
HTTPClient: &http.Client{Timeout: 120 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
type PromptResponse struct {
|
||||
PromptID string `json:"prompt_id"`
|
||||
Number int `json:"number"`
|
||||
}
|
||||
|
||||
func (c *Client) SubmitPrompt(ctx context.Context, prompt map[string]any, clientID string) (*PromptResponse, error) {
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"prompt": prompt,
|
||||
"client_id": clientID,
|
||||
})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/prompt", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("comfyui prompt %d: %s", resp.StatusCode, string(data))
|
||||
}
|
||||
var out PromptResponse
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (c *Client) Interrupt(ctx context.Context) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/interrupt", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("comfyui interrupt %d: %s", resp.StatusCode, string(data))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type HistoryEntry struct {
|
||||
Outputs map[string]HistoryNodeOutput `json:"outputs"`
|
||||
Status map[string]any `json:"status"`
|
||||
}
|
||||
|
||||
type HistoryNodeOutput struct {
|
||||
Images []OutputImage `json:"images"`
|
||||
}
|
||||
|
||||
type OutputImage struct {
|
||||
Filename string `json:"filename"`
|
||||
Subfolder string `json:"subfolder"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
func (c *Client) GetHistory(ctx context.Context, promptID string) (map[string]HistoryEntry, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/history/"+promptID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("comfyui history %d: %s", resp.StatusCode, string(data))
|
||||
}
|
||||
var out map[string]HistoryEntry
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Client) DownloadView(ctx context.Context, filename, subfolder, imageType string) ([]byte, string, error) {
|
||||
q := url.Values{}
|
||||
q.Set("filename", filename)
|
||||
if subfolder != "" {
|
||||
q.Set("subfolder", subfolder)
|
||||
}
|
||||
if imageType == "" {
|
||||
imageType = "output"
|
||||
}
|
||||
q.Set("type", imageType)
|
||||
u := c.BaseURL + "/view?" + q.Encode()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, "", fmt.Errorf("comfyui view %d", resp.StatusCode)
|
||||
}
|
||||
mime := resp.Header.Get("Content-Type")
|
||||
if mime == "" {
|
||||
mime = "image/png"
|
||||
}
|
||||
return data, mime, nil
|
||||
}
|
||||
|
||||
func (c *Client) UploadImage(ctx context.Context, data []byte, filename string) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
part, err := w.CreateFormFile("image", filename)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := part.Write(data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = w.WriteField("overwrite", "true")
|
||||
_ = w.Close()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/upload/image", &buf)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
return "", fmt.Errorf("comfyui upload %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
var out struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return out.Name, nil
|
||||
}
|
||||
|
||||
func (c *Client) HealthCheck(ctx context.Context) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/system_stats", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ProgressEvent struct {
|
||||
Type string
|
||||
Data map[string]any
|
||||
Node string
|
||||
Value float64
|
||||
Max float64
|
||||
PromptID string
|
||||
ErrorText string
|
||||
}
|
||||
|
||||
func (c *Client) wsURL(clientID string) string {
|
||||
u, _ := url.Parse(c.BaseURL)
|
||||
scheme := "ws"
|
||||
if u.Scheme == "https" {
|
||||
scheme = "wss"
|
||||
}
|
||||
return fmt.Sprintf("%s://%s/ws?clientId=%s", scheme, u.Host, url.QueryEscape(clientID))
|
||||
}
|
||||
|
||||
func (c *Client) Watch(ctx context.Context, clientID string, onEvent func(ProgressEvent)) error {
|
||||
conn, _, err := websocket.DefaultDialer.DialContext(ctx, c.wsURL(clientID), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
_, msg, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var envelope struct {
|
||||
Type string `json:"type"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(msg, &envelope); err != nil {
|
||||
continue
|
||||
}
|
||||
ev := ProgressEvent{Type: envelope.Type}
|
||||
var data map[string]any
|
||||
_ = json.Unmarshal(envelope.Data, &data)
|
||||
ev.Data = data
|
||||
if envelope.Type == "progress" {
|
||||
if n, ok := data["node"].(string); ok {
|
||||
ev.Node = n
|
||||
}
|
||||
if v, ok := data["value"].(float64); ok {
|
||||
ev.Value = v
|
||||
}
|
||||
if m, ok := data["max"].(float64); ok {
|
||||
ev.Max = m
|
||||
}
|
||||
}
|
||||
if envelope.Type == "executing" {
|
||||
if n, ok := data["node"].(string); ok {
|
||||
ev.Node = n
|
||||
}
|
||||
if pid, ok := data["prompt_id"].(string); ok {
|
||||
ev.PromptID = pid
|
||||
}
|
||||
}
|
||||
if envelope.Type == "execution_error" {
|
||||
if msgs, ok := data["exception_message"].(string); ok {
|
||||
ev.ErrorText = msgs
|
||||
}
|
||||
}
|
||||
onEvent(ev)
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
|
||||
<-done
|
||||
return ctx.Err()
|
||||
case <-done:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func CollectOutputImages(history map[string]HistoryEntry, promptID string) []OutputImage {
|
||||
entry, ok := history[promptID]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var images []OutputImage
|
||||
for _, out := range entry.Outputs {
|
||||
images = append(images, out.Images...)
|
||||
}
|
||||
return images
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package custom
|
||||
|
||||
import (
|
||||
"github.com/rose_cat707/luminary/internal/provider/openai"
|
||||
)
|
||||
|
||||
// Custom providers use OpenAI-compatible API at a custom base URL.
|
||||
type Client struct {
|
||||
*openai.Client
|
||||
}
|
||||
|
||||
func New() *Client {
|
||||
return &Client{Client: openai.New()}
|
||||
}
|
||||
|
||||
func (c *Client) Type() string { return "custom" }
|
||||
@@ -0,0 +1,176 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/rose_cat707/luminary/internal/model"
|
||||
)
|
||||
|
||||
// EndpointType 接口类型,对应入口 /v1/* 与上游 path 映射
|
||||
type EndpointType string
|
||||
|
||||
const (
|
||||
EndpointListModels EndpointType = "list_models"
|
||||
EndpointTextCompletion EndpointType = "text_completion"
|
||||
EndpointChatCompletion EndpointType = "chat_completion"
|
||||
EndpointResponses EndpointType = "responses"
|
||||
EndpointEmbeddings EndpointType = "embeddings"
|
||||
EndpointRerank EndpointType = "rerank"
|
||||
EndpointSpeech EndpointType = "speech"
|
||||
EndpointTranscription EndpointType = "transcription"
|
||||
EndpointImageGeneration EndpointType = "image_generation"
|
||||
)
|
||||
|
||||
type EndpointMeta struct {
|
||||
Key EndpointType `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Method string `json:"method"`
|
||||
IngressPath string `json:"ingress_path"`
|
||||
Categories []model.ProviderCategory `json:"categories"`
|
||||
}
|
||||
|
||||
var AllEndpoints = []EndpointMeta{
|
||||
{Key: EndpointListModels, Label: "List Models", Method: "GET", IngressPath: "/v1/models", Categories: []model.ProviderCategory{model.CategoryLLM, model.CategoryEmbedding, model.CategoryRerank}},
|
||||
{Key: EndpointTextCompletion, Label: "Text Completion", Method: "POST", IngressPath: "/v1/completions", Categories: []model.ProviderCategory{model.CategoryLLM}},
|
||||
{Key: EndpointChatCompletion, Label: "Chat Completion", Method: "POST", IngressPath: "/v1/chat/completions", Categories: []model.ProviderCategory{model.CategoryLLM}},
|
||||
{Key: EndpointResponses, Label: "Responses", Method: "POST", IngressPath: "/v1/responses", Categories: []model.ProviderCategory{model.CategoryLLM}},
|
||||
{Key: EndpointEmbeddings, Label: "Embeddings", Method: "POST", IngressPath: "/v1/embeddings", Categories: []model.ProviderCategory{model.CategoryEmbedding}},
|
||||
{Key: EndpointRerank, Label: "Rerank", Method: "POST", IngressPath: "/v1/rerank", Categories: []model.ProviderCategory{model.CategoryRerank}},
|
||||
{Key: EndpointSpeech, Label: "Speech", Method: "POST", IngressPath: "/v1/audio/speech", Categories: []model.ProviderCategory{model.CategorySpeech}},
|
||||
{Key: EndpointTranscription, Label: "Transcription", Method: "POST", IngressPath: "/v1/audio/transcriptions", Categories: []model.ProviderCategory{model.CategoryAudio}},
|
||||
{Key: EndpointImageGeneration, Label: "Image Generation", Method: "POST", IngressPath: "/v1/images/generations", Categories: []model.ProviderCategory{model.CategoryImage}},
|
||||
}
|
||||
|
||||
// DefaultPaths 各 Provider 类型的默认上游 path(相对 base_url)
|
||||
var DefaultPaths = map[model.ProviderType]map[EndpointType]string{
|
||||
model.ProviderOpenAI: {
|
||||
EndpointListModels: "/models",
|
||||
EndpointTextCompletion: "/completions",
|
||||
EndpointChatCompletion: "/chat/completions",
|
||||
EndpointResponses: "/responses",
|
||||
EndpointEmbeddings: "/embeddings",
|
||||
EndpointRerank: "/rerank",
|
||||
EndpointSpeech: "/audio/speech",
|
||||
EndpointTranscription: "/audio/transcriptions",
|
||||
EndpointImageGeneration: "/images/generations",
|
||||
},
|
||||
model.ProviderAnthropic: {
|
||||
EndpointListModels: "/models",
|
||||
EndpointChatCompletion: "/messages",
|
||||
},
|
||||
model.ProviderCustom: {
|
||||
EndpointListModels: "/models",
|
||||
EndpointTextCompletion: "/completions",
|
||||
EndpointChatCompletion: "/chat/completions",
|
||||
EndpointResponses: "/responses",
|
||||
EndpointEmbeddings: "/embeddings",
|
||||
EndpointRerank: "/rerank",
|
||||
EndpointSpeech: "/audio/speech",
|
||||
EndpointTranscription: "/audio/transcriptions",
|
||||
EndpointImageGeneration: "/images/generations",
|
||||
},
|
||||
model.ProviderAzureOpenAI: {
|
||||
EndpointListModels: "/models",
|
||||
EndpointChatCompletion: "/chat/completions",
|
||||
EndpointResponses: "/responses",
|
||||
EndpointEmbeddings: "/embeddings",
|
||||
EndpointRerank: "/rerank",
|
||||
},
|
||||
model.ProviderOpenRouter: {
|
||||
EndpointListModels: "/models",
|
||||
EndpointChatCompletion: "/chat/completions",
|
||||
EndpointResponses: "/responses",
|
||||
EndpointEmbeddings: "/embeddings",
|
||||
EndpointRerank: "/rerank",
|
||||
},
|
||||
model.ProviderOllama: {
|
||||
EndpointListModels: "/models",
|
||||
EndpointChatCompletion: "/chat/completions",
|
||||
EndpointEmbeddings: "/embeddings",
|
||||
EndpointRerank: "/rerank",
|
||||
},
|
||||
model.ProviderComfyUI: {},
|
||||
}
|
||||
|
||||
func DefaultPath(providerType model.ProviderType, endpoint EndpointType) string {
|
||||
if m, ok := DefaultPaths[providerType]; ok {
|
||||
if p, ok := m[endpoint]; ok {
|
||||
return p
|
||||
}
|
||||
}
|
||||
if p, ok := DefaultPaths[model.ProviderOpenAI][endpoint]; ok {
|
||||
return p
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// DefaultEndpointsForCategory 创建出口时默认启用的 endpoint keys
|
||||
func DefaultEndpointsForCategory(cat model.ProviderCategory) []string {
|
||||
switch cat {
|
||||
case model.CategoryEmbedding:
|
||||
return []string{string(EndpointListModels), string(EndpointEmbeddings)}
|
||||
case model.CategoryRerank:
|
||||
return []string{string(EndpointListModels), string(EndpointRerank)}
|
||||
case model.CategoryImage:
|
||||
return []string{}
|
||||
default:
|
||||
return []string{string(EndpointListModels), string(EndpointChatCompletion)}
|
||||
}
|
||||
}
|
||||
|
||||
func EndpointsForCategory(cat model.ProviderCategory) []EndpointMeta {
|
||||
var out []EndpointMeta
|
||||
for _, e := range AllEndpoints {
|
||||
for _, c := range e.Categories {
|
||||
if c == cat {
|
||||
out = append(out, e)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ResolveURL 解析最终请求 URL:override 可为相对 path 或完整 URL
|
||||
func ResolveURL(baseURL, override, defaultPath string) string {
|
||||
if override != "" {
|
||||
if strings.HasPrefix(override, "http://") || strings.HasPrefix(override, "https://") {
|
||||
return override
|
||||
}
|
||||
return joinURL(baseURL, override)
|
||||
}
|
||||
return joinURL(baseURL, defaultPath)
|
||||
}
|
||||
|
||||
func joinURL(base, path string) string {
|
||||
base = strings.TrimRight(base, "/")
|
||||
if path == "" {
|
||||
return base
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
if base == "" {
|
||||
return path
|
||||
}
|
||||
return base + path
|
||||
}
|
||||
|
||||
func CategoryLabel(cat model.ProviderCategory) string {
|
||||
switch cat {
|
||||
case model.CategoryLLM:
|
||||
return "语言模型"
|
||||
case model.CategoryEmbedding:
|
||||
return "向量嵌入"
|
||||
case model.CategoryRerank:
|
||||
return "重排序"
|
||||
case model.CategorySpeech:
|
||||
return "语音合成"
|
||||
case model.CategoryImage:
|
||||
return "图像生成"
|
||||
case model.CategoryAudio:
|
||||
return "音频转写"
|
||||
default:
|
||||
return string(cat)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/rose_cat707/luminary/internal/model"
|
||||
)
|
||||
|
||||
type ForwardRequest struct {
|
||||
Endpoint EndpointType
|
||||
Method string
|
||||
Body []byte
|
||||
Headers map[string]string
|
||||
}
|
||||
|
||||
type ForwardResponse struct {
|
||||
StatusCode int
|
||||
Body []byte
|
||||
TokensIn int
|
||||
TokensOut int
|
||||
}
|
||||
|
||||
type ProviderConfig struct {
|
||||
Type model.ProviderType
|
||||
Category model.ProviderCategory
|
||||
BaseURL string
|
||||
EndpointPaths map[string]string // endpoint key -> path override
|
||||
}
|
||||
|
||||
func ParseEndpointPaths(jsonStr string) map[string]string {
|
||||
out := map[string]string{}
|
||||
if jsonStr == "" || jsonStr == "{}" {
|
||||
return out
|
||||
}
|
||||
_ = json.Unmarshal([]byte(jsonStr), &out)
|
||||
return out
|
||||
}
|
||||
|
||||
func ParseAllowedEndpoints(jsonStr string, category model.ProviderCategory) []string {
|
||||
var out []string
|
||||
if jsonStr == "" {
|
||||
return DefaultEndpointsForCategory(category)
|
||||
}
|
||||
_ = json.Unmarshal([]byte(jsonStr), &out)
|
||||
if len(out) == 0 {
|
||||
return DefaultEndpointsForCategory(category)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (cfg *ProviderConfig) ResolveEndpointURL(endpoint EndpointType) string {
|
||||
override := cfg.EndpointPaths[string(endpoint)]
|
||||
defaultPath := DefaultPath(cfg.Type, endpoint)
|
||||
return ResolveURL(cfg.BaseURL, override, defaultPath)
|
||||
}
|
||||
|
||||
func ForwardHTTP(ctx context.Context, client *http.Client, method, url string, apiKey string, providerType model.ProviderType, body []byte, extraHeaders map[string]string) (*ForwardResponse, error) {
|
||||
var reader io.Reader
|
||||
if len(body) > 0 {
|
||||
reader = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setAuthHeaders(req, apiKey, providerType)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
for k, v := range extraHeaders {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokensIn, tokensOut := parseUsageJSON(respBody)
|
||||
return &ForwardResponse{
|
||||
StatusCode: resp.StatusCode,
|
||||
Body: respBody,
|
||||
TokensIn: tokensIn,
|
||||
TokensOut: tokensOut,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func setAuthHeaders(req *http.Request, apiKey string, providerType model.ProviderType) {
|
||||
switch providerType {
|
||||
case model.ProviderAnthropic:
|
||||
req.Header.Set("x-api-key", apiKey)
|
||||
req.Header.Set("anthropic-version", "2023-06-01")
|
||||
case model.ProviderAzureOpenAI:
|
||||
if apiKey != "" {
|
||||
req.Header.Set("api-key", apiKey)
|
||||
}
|
||||
case model.ProviderOllama:
|
||||
if apiKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
default:
|
||||
if apiKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseUsageJSON(body []byte) (int, int) {
|
||||
var u struct {
|
||||
Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &u)
|
||||
in := u.Usage.PromptTokens
|
||||
out := u.Usage.CompletionTokens
|
||||
if in == 0 {
|
||||
in = u.Usage.InputTokens
|
||||
}
|
||||
if out == 0 {
|
||||
out = u.Usage.OutputTokens
|
||||
}
|
||||
return in, out
|
||||
}
|
||||
|
||||
func DefaultBaseURL(providerType model.ProviderType) string {
|
||||
switch providerType {
|
||||
case model.ProviderAnthropic:
|
||||
return "https://api.anthropic.com/v1"
|
||||
case model.ProviderAzureOpenAI:
|
||||
return "" // must be configured per deployment
|
||||
case model.ProviderOpenRouter:
|
||||
return "https://openrouter.ai/api/v1"
|
||||
case model.ProviderOllama:
|
||||
return "http://127.0.0.1:11434/v1"
|
||||
default:
|
||||
return "https://api.openai.com/v1"
|
||||
}
|
||||
}
|
||||
|
||||
func IsOpenAICompatible(t model.ProviderType) bool {
|
||||
switch t {
|
||||
case model.ProviderOpenAI, model.ProviderCustom, model.ProviderAzureOpenAI, model.ProviderOpenRouter, model.ProviderOllama:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeBaseURL(baseURL string, providerType model.ProviderType) string {
|
||||
if baseURL == "" {
|
||||
return DefaultBaseURL(providerType)
|
||||
}
|
||||
return baseURL
|
||||
}
|
||||
|
||||
func IsEndpointAllowed(allowed []string, endpoint EndpointType) bool {
|
||||
for _, a := range allowed {
|
||||
if a == string(endpoint) || a == "*" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func NewHTTPClient() *http.Client {
|
||||
return &http.Client{Timeout: 120 * time.Second}
|
||||
}
|
||||
|
||||
func ValidateCategoryEndpoints(category model.ProviderCategory, allowed []string) error {
|
||||
if !model.IsCategorySupported(category) {
|
||||
return fmt.Errorf("category %s is not supported yet", category)
|
||||
}
|
||||
valid := EndpointsForCategory(category)
|
||||
validSet := map[string]bool{}
|
||||
for _, e := range valid {
|
||||
validSet[string(e.Key)] = true
|
||||
}
|
||||
for _, a := range allowed {
|
||||
if a == "*" {
|
||||
continue
|
||||
}
|
||||
if !validSet[a] {
|
||||
return fmt.Errorf("endpoint %s is not available for category %s", a, category)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package provider
|
||||
|
||||
import "net/http"
|
||||
|
||||
type HTTPClientWrapper struct {
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
func NewHTTPClientWrapper() *HTTPClientWrapper {
|
||||
return &HTTPClientWrapper{Client: NewHTTPClient()}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/rose_cat707/luminary/internal/model"
|
||||
"github.com/rose_cat707/luminary/internal/provider"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
http *provider.HTTPClientWrapper
|
||||
}
|
||||
|
||||
func New() *Client {
|
||||
return &Client{http: provider.NewHTTPClientWrapper()}
|
||||
}
|
||||
|
||||
func (c *Client) Type() string { return "openai" }
|
||||
|
||||
func (c *Client) cfg(baseURL string) provider.ProviderConfig {
|
||||
return provider.ProviderConfig{
|
||||
Type: model.ProviderOpenAI,
|
||||
Category: model.CategoryLLM,
|
||||
BaseURL: provider.NormalizeBaseURL(baseURL, model.ProviderOpenAI),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) ListModels(ctx context.Context, apiKey, baseURL string) ([]provider.ModelInfo, error) {
|
||||
return c.ListModelsWithConfig(ctx, apiKey, provider.ProviderConfig{
|
||||
Type: model.ProviderOpenAI,
|
||||
BaseURL: provider.NormalizeBaseURL(baseURL, model.ProviderOpenAI),
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) ListModelsWithConfig(ctx context.Context, apiKey string, cfg provider.ProviderConfig) ([]provider.ModelInfo, error) {
|
||||
cfg.Type = model.ProviderOpenAI
|
||||
cfg.BaseURL = provider.NormalizeBaseURL(cfg.BaseURL, model.ProviderOpenAI)
|
||||
url := cfg.ResolveEndpointURL(provider.EndpointListModels)
|
||||
resp, err := provider.ForwardHTTP(ctx, c.http.Client, "GET", url, apiKey, cfg.Type, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("list models: %s", string(resp.Body))
|
||||
}
|
||||
var out struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Body, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
models := make([]provider.ModelInfo, 0, len(out.Data))
|
||||
for _, m := range out.Data {
|
||||
models = append(models, provider.ModelInfo{ID: m.ID, DisplayName: m.ID})
|
||||
}
|
||||
return models, nil
|
||||
}
|
||||
|
||||
func (c *Client) ChatCompletion(ctx context.Context, apiKey, baseURL string, req *provider.ChatRequest) (*provider.ChatResponse, error) {
|
||||
return c.ForwardWithConfig(ctx, apiKey, provider.ProviderConfig{
|
||||
Type: model.ProviderOpenAI,
|
||||
BaseURL: provider.NormalizeBaseURL(baseURL, model.ProviderOpenAI),
|
||||
}, provider.EndpointChatCompletion, req.Raw)
|
||||
}
|
||||
|
||||
func (c *Client) ForwardWithConfig(ctx context.Context, apiKey string, cfg provider.ProviderConfig, endpoint provider.EndpointType, body []byte) (*provider.ChatResponse, error) {
|
||||
cfg.Type = model.ProviderOpenAI
|
||||
cfg.BaseURL = provider.NormalizeBaseURL(cfg.BaseURL, model.ProviderOpenAI)
|
||||
url := cfg.ResolveEndpointURL(endpoint)
|
||||
resp, err := provider.ForwardHTTP(ctx, c.http.Client, "POST", url, apiKey, cfg.Type, body, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &provider.ChatResponse{
|
||||
StatusCode: resp.StatusCode,
|
||||
Body: resp.Body,
|
||||
TokensIn: resp.TokensIn,
|
||||
TokensOut: resp.TokensOut,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) HealthCheck(ctx context.Context, apiKey, baseURL string) error {
|
||||
_, err := c.ListModels(ctx, apiKey, baseURL)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) HealthCheckWithConfig(ctx context.Context, apiKey string, cfg provider.ProviderConfig) error {
|
||||
_, err := c.ListModelsWithConfig(ctx, apiKey, cfg)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
type ModelInfo struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
}
|
||||
|
||||
type ChatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages json.RawMessage `json:"messages"`
|
||||
Stream bool `json:"stream"`
|
||||
Raw json.RawMessage `json:"-"`
|
||||
}
|
||||
|
||||
type ChatResponse struct {
|
||||
StatusCode int
|
||||
Body []byte
|
||||
Headers map[string]string
|
||||
TokensIn int
|
||||
TokensOut int
|
||||
}
|
||||
|
||||
type Client interface {
|
||||
Type() string
|
||||
ListModels(ctx context.Context, apiKey, baseURL string) ([]ModelInfo, error)
|
||||
ChatCompletion(ctx context.Context, apiKey, baseURL string, req *ChatRequest) (*ChatResponse, error)
|
||||
HealthCheck(ctx context.Context, apiKey, baseURL string) error
|
||||
}
|
||||
@@ -0,0 +1,640 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CanAdaptResponses reports whether responses ingress can be served via chat completions.
|
||||
func CanAdaptResponses(allowsResponses, allowsChat bool) bool {
|
||||
return allowsChat && !allowsResponses
|
||||
}
|
||||
|
||||
// ResponsesToChatCompletion converts an OpenAI Responses API request to chat completions format.
|
||||
func ResponsesToChatCompletion(body []byte) ([]byte, error) {
|
||||
var req map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return nil, fmt.Errorf("invalid json: %w", err)
|
||||
}
|
||||
|
||||
out := make(map[string]any)
|
||||
if raw, ok := req["model"]; ok {
|
||||
var model string
|
||||
if err := json.Unmarshal(raw, &model); err != nil || model == "" {
|
||||
return nil, fmt.Errorf("model is required")
|
||||
}
|
||||
out["model"] = model
|
||||
}
|
||||
|
||||
messages := make([]map[string]any, 0, 4)
|
||||
if raw, ok := req["instructions"]; ok {
|
||||
var instructions string
|
||||
if err := json.Unmarshal(raw, &instructions); err == nil && instructions != "" {
|
||||
messages = append(messages, map[string]any{"role": "system", "content": instructions})
|
||||
} else {
|
||||
var instructionParts []json.RawMessage
|
||||
if err := json.Unmarshal(raw, &instructionParts); err == nil {
|
||||
if text := joinContentPartTexts(instructionParts); text != "" {
|
||||
messages = append(messages, map[string]any{"role": "system", "content": text})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if raw, ok := req["input"]; ok {
|
||||
inputMsgs, err := parseResponsesInput(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
messages = append(messages, inputMsgs...)
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
return nil, fmt.Errorf("input is required")
|
||||
}
|
||||
out["messages"] = messages
|
||||
|
||||
copyField(req, out, "stream")
|
||||
copyField(req, out, "temperature")
|
||||
copyField(req, out, "top_p")
|
||||
copyField(req, out, "user")
|
||||
copyField(req, out, "stop")
|
||||
copyField(req, out, "tools")
|
||||
copyField(req, out, "tool_choice")
|
||||
copyField(req, out, "parallel_tool_calls")
|
||||
copyField(req, out, "seed")
|
||||
copyField(req, out, "n")
|
||||
copyField(req, out, "presence_penalty")
|
||||
copyField(req, out, "frequency_penalty")
|
||||
copyField(req, out, "logit_bias")
|
||||
copyField(req, out, "response_format")
|
||||
|
||||
if raw, ok := req["max_output_tokens"]; ok {
|
||||
var v int
|
||||
if err := json.Unmarshal(raw, &v); err == nil && v > 0 {
|
||||
out["max_tokens"] = v
|
||||
}
|
||||
} else {
|
||||
copyField(req, out, "max_tokens")
|
||||
}
|
||||
|
||||
if raw, ok := req["text"]; ok {
|
||||
if rf := responsesTextToResponseFormat(raw); rf != nil {
|
||||
out["response_format"] = rf
|
||||
}
|
||||
}
|
||||
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func copyField(src map[string]json.RawMessage, dst map[string]any, key string) {
|
||||
if raw, ok := src[key]; ok && len(raw) > 0 && string(raw) != "null" {
|
||||
var v any
|
||||
if json.Unmarshal(raw, &v) == nil {
|
||||
dst[key] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func responsesTextToResponseFormat(raw json.RawMessage) any {
|
||||
var text struct {
|
||||
Format struct {
|
||||
Type string `json:"type"`
|
||||
} `json:"format"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &text); err != nil || text.Format.Type == "" {
|
||||
return nil
|
||||
}
|
||||
switch text.Format.Type {
|
||||
case "json_object":
|
||||
return map[string]any{"type": "json_object"}
|
||||
case "text":
|
||||
return map[string]any{"type": "text"}
|
||||
default:
|
||||
return map[string]any{"type": text.Format.Type}
|
||||
}
|
||||
}
|
||||
|
||||
func parseResponsesInput(raw json.RawMessage) ([]map[string]any, error) {
|
||||
var asString string
|
||||
if err := json.Unmarshal(raw, &asString); err == nil {
|
||||
if asString == "" {
|
||||
return nil, fmt.Errorf("input is required")
|
||||
}
|
||||
return []map[string]any{{"role": "user", "content": asString}}, nil
|
||||
}
|
||||
|
||||
var asArray []json.RawMessage
|
||||
if err := json.Unmarshal(raw, &asArray); err != nil {
|
||||
return nil, fmt.Errorf("invalid input")
|
||||
}
|
||||
out := make([]map[string]any, 0, len(asArray))
|
||||
for _, item := range asArray {
|
||||
msg, err := parseResponsesInputItem(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, msg)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseResponsesInputItem(raw json.RawMessage) (map[string]any, error) {
|
||||
var asString string
|
||||
if err := json.Unmarshal(raw, &asString); err == nil {
|
||||
return map[string]any{"role": "user", "content": asString}, nil
|
||||
}
|
||||
|
||||
var item map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &item); err != nil {
|
||||
return nil, fmt.Errorf("invalid input item")
|
||||
}
|
||||
|
||||
itemType := ""
|
||||
if rawType, ok := item["type"]; ok {
|
||||
_ = json.Unmarshal(rawType, &itemType)
|
||||
}
|
||||
|
||||
role := "user"
|
||||
if rawRole, ok := item["role"]; ok {
|
||||
_ = json.Unmarshal(rawRole, &role)
|
||||
}
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
role = normalizeResponsesRole(role)
|
||||
|
||||
switch itemType {
|
||||
case "message":
|
||||
if rawContent, ok := item["content"]; ok {
|
||||
content, err := parseResponsesContent(rawContent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"role": role, "content": content}, nil
|
||||
}
|
||||
case "input_text":
|
||||
var text string
|
||||
if rawText, ok := item["text"]; ok {
|
||||
_ = json.Unmarshal(rawText, &text)
|
||||
}
|
||||
if text != "" {
|
||||
return map[string]any{"role": role, "content": text}, nil
|
||||
}
|
||||
case "function_call_output":
|
||||
return parseFunctionCallOutputItem(item, role)
|
||||
}
|
||||
|
||||
if rawContent, ok := item["content"]; ok {
|
||||
content, err := parseResponsesContent(rawContent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"role": role, "content": content}, nil
|
||||
}
|
||||
if rawText, ok := item["text"]; ok {
|
||||
var text string
|
||||
if err := json.Unmarshal(rawText, &text); err == nil {
|
||||
return map[string]any{"role": role, "content": text}, nil
|
||||
}
|
||||
}
|
||||
if rawInput, ok := item["input"]; ok {
|
||||
var text string
|
||||
if err := json.Unmarshal(rawInput, &text); err == nil {
|
||||
return map[string]any{"role": role, "content": text}, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported input item")
|
||||
}
|
||||
|
||||
func normalizeResponsesRole(role string) string {
|
||||
switch role {
|
||||
case "developer":
|
||||
return "system"
|
||||
default:
|
||||
return role
|
||||
}
|
||||
}
|
||||
|
||||
func parseResponsesContent(raw json.RawMessage) (any, error) {
|
||||
var asString string
|
||||
if err := json.Unmarshal(raw, &asString); err == nil {
|
||||
return asString, nil
|
||||
}
|
||||
var parts []json.RawMessage
|
||||
if err := json.Unmarshal(raw, &parts); err != nil {
|
||||
return nil, fmt.Errorf("invalid content")
|
||||
}
|
||||
converted := make([]any, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if item, ok := convertResponsesContentPart(part); ok {
|
||||
converted = append(converted, item)
|
||||
}
|
||||
}
|
||||
if len(converted) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
if len(converted) == 1 {
|
||||
if text, ok := converted[0].(string); ok {
|
||||
return text, nil
|
||||
}
|
||||
if m, ok := converted[0].(map[string]any); ok {
|
||||
if m["type"] == "text" {
|
||||
if text, ok := m["text"].(string); ok {
|
||||
return text, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return converted, nil
|
||||
}
|
||||
|
||||
func convertResponsesContentPart(raw json.RawMessage) (any, bool) {
|
||||
var asString string
|
||||
if err := json.Unmarshal(raw, &asString); err == nil {
|
||||
return asString, true
|
||||
}
|
||||
var part map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &part); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
typ := ""
|
||||
if rawType, ok := part["type"]; ok {
|
||||
_ = json.Unmarshal(rawType, &typ)
|
||||
}
|
||||
switch typ {
|
||||
case "input_text", "text", "output_text":
|
||||
var text string
|
||||
if rawText, ok := part["text"]; ok {
|
||||
_ = json.Unmarshal(rawText, &text)
|
||||
}
|
||||
if text == "" {
|
||||
return nil, false
|
||||
}
|
||||
return map[string]any{"type": "text", "text": text}, true
|
||||
case "input_image":
|
||||
out := map[string]any{"type": "image_url", "image_url": map[string]any{}}
|
||||
img := out["image_url"].(map[string]any)
|
||||
if rawURL, ok := part["image_url"]; ok {
|
||||
var url string
|
||||
if err := json.Unmarshal(rawURL, &url); err == nil {
|
||||
img["url"] = url
|
||||
} else {
|
||||
var nested map[string]any
|
||||
if err := json.Unmarshal(rawURL, &nested); err == nil {
|
||||
for k, v := range nested {
|
||||
img[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if rawDetail, ok := part["detail"]; ok {
|
||||
var detail string
|
||||
if json.Unmarshal(rawDetail, &detail) == nil {
|
||||
img["detail"] = detail
|
||||
}
|
||||
}
|
||||
if img["url"] == nil {
|
||||
return nil, false
|
||||
}
|
||||
return out, true
|
||||
case "image_url":
|
||||
var v any
|
||||
_ = json.Unmarshal(raw, &v)
|
||||
return v, true
|
||||
case "input_file":
|
||||
var fileID, filename string
|
||||
if rawID, ok := part["file_id"]; ok {
|
||||
_ = json.Unmarshal(rawID, &fileID)
|
||||
}
|
||||
if rawName, ok := part["filename"]; ok {
|
||||
_ = json.Unmarshal(rawName, &filename)
|
||||
}
|
||||
label := filename
|
||||
if label == "" {
|
||||
label = fileID
|
||||
}
|
||||
if label == "" {
|
||||
return nil, false
|
||||
}
|
||||
return map[string]any{"type": "text", "text": "[file:" + label + "]"}, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func joinContentPartTexts(parts []json.RawMessage) string {
|
||||
var texts []string
|
||||
for _, part := range parts {
|
||||
if item, ok := convertResponsesContentPart(part); ok {
|
||||
if text, ok := item.(string); ok {
|
||||
texts = append(texts, text)
|
||||
} else if m, ok := item.(map[string]any); ok && m["type"] == "text" {
|
||||
if text, ok := m["text"].(string); ok {
|
||||
texts = append(texts, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(texts, "\n")
|
||||
}
|
||||
|
||||
func parseFunctionCallOutputItem(item map[string]json.RawMessage, role string) (map[string]any, error) {
|
||||
var output, callID string
|
||||
if rawOutput, ok := item["output"]; ok {
|
||||
_ = json.Unmarshal(rawOutput, &output)
|
||||
}
|
||||
if rawCallID, ok := item["call_id"]; ok {
|
||||
_ = json.Unmarshal(rawCallID, &callID)
|
||||
}
|
||||
if output == "" {
|
||||
return nil, fmt.Errorf("unsupported input item")
|
||||
}
|
||||
msg := map[string]any{"role": "tool", "content": output}
|
||||
if callID != "" {
|
||||
msg["tool_call_id"] = callID
|
||||
}
|
||||
if role != "" && role != "user" {
|
||||
msg["role"] = role
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// ChatCompletionToResponses converts a chat completions response to Responses API format.
|
||||
func ChatCompletionToResponses(body []byte) ([]byte, error) {
|
||||
var chat map[string]any
|
||||
if err := json.Unmarshal(body, &chat); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, ok := chat["error"]; ok {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
content := extractChatContent(chat)
|
||||
chatID, _ := chat["id"].(string)
|
||||
model, _ := chat["model"].(string)
|
||||
created := extractCreatedAt(chat)
|
||||
respID := responsesIDFromChatID(chatID)
|
||||
msgID := messageIDFromResponseID(respID)
|
||||
usage := chatUsageToResponses(chat["usage"])
|
||||
|
||||
resp := buildResponseObject(respID, msgID, model, created, content, usage, "completed")
|
||||
return json.Marshal(resp)
|
||||
}
|
||||
|
||||
func extractCreatedAt(chat map[string]any) int64 {
|
||||
switch v := chat["created"].(type) {
|
||||
case float64:
|
||||
return int64(v)
|
||||
case int64:
|
||||
return v
|
||||
case int:
|
||||
return int64(v)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func responsesIDFromChatID(chatID string) string {
|
||||
if chatID == "" {
|
||||
return "resp_adapted"
|
||||
}
|
||||
if strings.HasPrefix(chatID, "resp_") {
|
||||
return chatID
|
||||
}
|
||||
if strings.HasPrefix(chatID, "chatcmpl-") {
|
||||
return "resp_" + strings.TrimPrefix(chatID, "chatcmpl-")
|
||||
}
|
||||
return "resp_" + chatID
|
||||
}
|
||||
|
||||
func messageIDFromResponseID(respID string) string {
|
||||
if strings.HasPrefix(respID, "resp_") {
|
||||
return "msg_" + strings.TrimPrefix(respID, "resp_")
|
||||
}
|
||||
return "msg_" + respID
|
||||
}
|
||||
|
||||
func buildOutputTextContent(text string) map[string]any {
|
||||
return map[string]any{
|
||||
"type": "output_text",
|
||||
"text": text,
|
||||
"annotations": []any{},
|
||||
}
|
||||
}
|
||||
|
||||
func buildOutputMessage(msgID, content, status string) map[string]any {
|
||||
return map[string]any{
|
||||
"type": "message",
|
||||
"id": msgID,
|
||||
"role": "assistant",
|
||||
"status": status,
|
||||
"content": []map[string]any{buildOutputTextContent(content)},
|
||||
}
|
||||
}
|
||||
|
||||
func buildResponseObject(respID, msgID, model string, created int64, content string, usage map[string]any, status string) map[string]any {
|
||||
msgStatus := status
|
||||
if status == "in_progress" || status == "queued" {
|
||||
msgStatus = "in_progress"
|
||||
}
|
||||
resp := map[string]any{
|
||||
"id": respID,
|
||||
"object": "response",
|
||||
"created_at": created,
|
||||
"status": status,
|
||||
"model": model,
|
||||
"output_text": content,
|
||||
"error": nil,
|
||||
"incomplete_details": nil,
|
||||
"parallel_tool_calls": true,
|
||||
"output": []map[string]any{buildOutputMessage(msgID, content, msgStatus)},
|
||||
}
|
||||
if usage != nil {
|
||||
resp["usage"] = usage
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func extractChatContent(chat map[string]any) string {
|
||||
choices, ok := chat["choices"].([]any)
|
||||
if !ok || len(choices) == 0 {
|
||||
return ""
|
||||
}
|
||||
choice, ok := choices[0].(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if msg, ok := choice["message"].(map[string]any); ok {
|
||||
if content, ok := msg["content"].(string); ok {
|
||||
return content
|
||||
}
|
||||
}
|
||||
if delta, ok := choice["delta"].(map[string]any); ok {
|
||||
if content, ok := delta["content"].(string); ok {
|
||||
return content
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func chatUsageToResponses(usage any) map[string]any {
|
||||
u, ok := usage.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
in, hasIn := numberAsInt(u["prompt_tokens"])
|
||||
out, hasOut := numberAsInt(u["completion_tokens"])
|
||||
total, hasTotal := numberAsInt(u["total_tokens"])
|
||||
if !hasIn && !hasOut && !hasTotal {
|
||||
return nil
|
||||
}
|
||||
if !hasTotal && hasIn && hasOut {
|
||||
total = in + out
|
||||
}
|
||||
return map[string]any{
|
||||
"input_tokens": in,
|
||||
"output_tokens": out,
|
||||
"total_tokens": total,
|
||||
"input_tokens_details": map[string]any{
|
||||
"cached_tokens": 0,
|
||||
},
|
||||
"output_tokens_details": map[string]any{
|
||||
"reasoning_tokens": 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func numberAsInt(v any) (int, bool) {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n), true
|
||||
case int:
|
||||
return n, true
|
||||
case int64:
|
||||
return int(n), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// AdaptResponsesStream wraps a chat-completions SSE stream as Responses API SSE events.
|
||||
func AdaptResponsesStream(chatBody []byte, upstream io.Reader, w io.Writer, flusher http.Flusher) (*StreamResult, error) {
|
||||
var req map[string]any
|
||||
_ = json.Unmarshal(chatBody, &req)
|
||||
model, _ := req["model"].(string)
|
||||
|
||||
result := &StreamResult{StatusCode: http.StatusOK}
|
||||
respID := "resp_adapted"
|
||||
msgID := "msg_adapted"
|
||||
seq := 0
|
||||
var fullText string
|
||||
var usage map[string]any
|
||||
started := false
|
||||
|
||||
writeEvent := func(eventType string, payload map[string]any) error {
|
||||
payload["type"] = eventType
|
||||
payload["sequence_number"] = seq
|
||||
seq++
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", eventType, b); err != nil {
|
||||
return err
|
||||
}
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
emitCreated := func(status string) error {
|
||||
response := buildResponseObject(respID, msgID, model, 0, "", nil, status)
|
||||
response["output"] = []any{}
|
||||
response["output_text"] = ""
|
||||
return writeEvent("response.created", map[string]any{"response": response})
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(upstream)
|
||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
if data == "[DONE]" {
|
||||
break
|
||||
}
|
||||
tokensIn, tokensOut := parseStreamChunk([]byte(data))
|
||||
result.TokensIn += tokensIn
|
||||
result.TokensOut += tokensOut
|
||||
|
||||
var chunk map[string]any
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
continue
|
||||
}
|
||||
if id, ok := chunk["id"].(string); ok && id != "" {
|
||||
respID = responsesIDFromChatID(id)
|
||||
msgID = messageIDFromResponseID(respID)
|
||||
}
|
||||
if m, ok := chunk["model"].(string); ok && m != "" {
|
||||
model = m
|
||||
}
|
||||
if u := chatUsageToResponses(chunk["usage"]); u != nil {
|
||||
usage = u
|
||||
}
|
||||
|
||||
delta := extractChatContent(chunk)
|
||||
if delta == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if !started {
|
||||
started = true
|
||||
if err := emitCreated("in_progress"); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if err := writeEvent("response.in_progress", map[string]any{
|
||||
"response": buildResponseObject(respID, msgID, model, 0, "", nil, "in_progress"),
|
||||
}); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if err := writeEvent("response.output_item.added", map[string]any{
|
||||
"output_index": 0,
|
||||
"item": map[string]any{
|
||||
"type": "message", "id": msgID, "role": "assistant", "status": "in_progress", "content": []any{},
|
||||
},
|
||||
}); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
|
||||
fullText += delta
|
||||
if err := writeEvent("response.output_text.delta", map[string]any{
|
||||
"item_id": msgID, "output_index": 0, "content_index": 0, "delta": delta, "logprobs": []any{},
|
||||
}); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
if started {
|
||||
_ = writeEvent("response.output_text.done", map[string]any{
|
||||
"item_id": msgID, "output_index": 0, "content_index": 0, "text": fullText, "logprobs": []any{},
|
||||
})
|
||||
doneItem := buildOutputMessage(msgID, fullText, "completed")
|
||||
_ = writeEvent("response.output_item.done", map[string]any{
|
||||
"output_index": 0,
|
||||
"item": doneItem,
|
||||
})
|
||||
final := buildResponseObject(respID, msgID, model, 0, fullText, usage, "completed")
|
||||
_ = writeEvent("response.completed", map[string]any{"response": final})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResponsesToChatCompletion_StringInput(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-4o-mini","instructions":"Be brief","input":"Hello"}`)
|
||||
out, err := ResponsesToChatCompletion(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var req map[string]any
|
||||
if err := json.Unmarshal(out, &req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if req["model"] != "gpt-4o-mini" {
|
||||
t.Fatalf("model: %v", req["model"])
|
||||
}
|
||||
msgs := req["messages"].([]any)
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("messages len: %d", len(msgs))
|
||||
}
|
||||
sys := msgs[0].(map[string]any)
|
||||
user := msgs[1].(map[string]any)
|
||||
if sys["role"] != "system" || sys["content"] != "Be brief" {
|
||||
t.Fatalf("system: %#v", sys)
|
||||
}
|
||||
if user["role"] != "user" || user["content"] != "Hello" {
|
||||
t.Fatalf("user: %#v", user)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCompletionToResponses(t *testing.T) {
|
||||
body := []byte(`{"id":"chatcmpl-abc123","model":"gpt-4o-mini","created":123,"choices":[{"message":{"role":"assistant","content":"Hi"}}],"usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}}`)
|
||||
out, err := ChatCompletionToResponses(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(out, &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp["object"] != "response" {
|
||||
t.Fatalf("object: %v", resp["object"])
|
||||
}
|
||||
if resp["id"] != "resp_abc123" {
|
||||
t.Fatalf("id: %v", resp["id"])
|
||||
}
|
||||
if resp["output_text"] != "Hi" {
|
||||
t.Fatalf("output_text: %v", resp["output_text"])
|
||||
}
|
||||
output := resp["output"].([]any)
|
||||
msg := output[0].(map[string]any)
|
||||
content := msg["content"].([]any)
|
||||
text := content[0].(map[string]any)
|
||||
if text["type"] != "output_text" || text["text"] != "Hi" {
|
||||
t.Fatalf("text: %#v", text)
|
||||
}
|
||||
if _, ok := text["annotations"]; !ok {
|
||||
t.Fatalf("missing annotations: %#v", text)
|
||||
}
|
||||
usage := resp["usage"].(map[string]any)
|
||||
if usage["input_tokens"].(float64) != 3 {
|
||||
t.Fatalf("usage: %#v", usage)
|
||||
}
|
||||
if _, ok := usage["input_tokens_details"]; !ok {
|
||||
t.Fatalf("missing input_tokens_details")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesToChatCompletion_InputTextParts(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"model":"gpt-4o-mini",
|
||||
"input":[{"role":"user","content":[{"type":"input_text","text":"Hello"}]}]
|
||||
}`)
|
||||
out, err := ResponsesToChatCompletion(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var req map[string]any
|
||||
if err := json.Unmarshal(out, &req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msgs := req["messages"].([]any)
|
||||
user := msgs[0].(map[string]any)
|
||||
if user["content"] != "Hello" {
|
||||
t.Fatalf("content: %#v", user["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesToChatCompletion_InputTextAndImage(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"model":"gpt-4o-mini",
|
||||
"input":[{"role":"user","content":[
|
||||
{"type":"input_text","text":"describe"},
|
||||
{"type":"input_image","image_url":"https://example.com/a.png","detail":"auto"}
|
||||
]}]
|
||||
}`)
|
||||
out, err := ResponsesToChatCompletion(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var req map[string]any
|
||||
if err := json.Unmarshal(out, &req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msgs := req["messages"].([]any)
|
||||
user := msgs[0].(map[string]any)
|
||||
parts := user["content"].([]any)
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("parts: %#v", parts)
|
||||
}
|
||||
textPart := parts[0].(map[string]any)
|
||||
if textPart["type"] != "text" || textPart["text"] != "describe" {
|
||||
t.Fatalf("text part: %#v", textPart)
|
||||
}
|
||||
imgPart := parts[1].(map[string]any)
|
||||
if imgPart["type"] != "image_url" {
|
||||
t.Fatalf("image part: %#v", imgPart)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesToChatCompletion_MessageItem(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"model":"gpt-4o-mini",
|
||||
"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"Hi"}]}]
|
||||
}`)
|
||||
out, err := ResponsesToChatCompletion(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(out), "input_text") {
|
||||
t.Fatalf("should not contain input_text: %s", out)
|
||||
}
|
||||
var req map[string]any
|
||||
if err := json.Unmarshal(out, &req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msgs := req["messages"].([]any)
|
||||
user := msgs[0].(map[string]any)
|
||||
if user["content"] != "Hi" {
|
||||
t.Fatalf("content: %#v", user["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdaptResponsesStream(t *testing.T) {
|
||||
upstream := strings.NewReader("data: {\"id\":\"chatcmpl-1\",\"model\":\"gpt-4o-mini\",\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}\n\ndata: [DONE]\n\n")
|
||||
var out strings.Builder
|
||||
result, err := AdaptResponsesStream([]byte(`{"model":"gpt-4o-mini","stream":true}`), upstream, &out, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.StatusCode != 200 {
|
||||
t.Fatalf("status: %d", result.StatusCode)
|
||||
}
|
||||
s := out.String()
|
||||
for _, event := range []string{
|
||||
"response.created",
|
||||
"response.in_progress",
|
||||
"response.output_item.added",
|
||||
"response.output_text.delta",
|
||||
"response.output_text.done",
|
||||
"response.output_item.done",
|
||||
"response.completed",
|
||||
} {
|
||||
if !strings.Contains(s, event) {
|
||||
t.Fatalf("missing event %s: %s", event, s)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(s, `"delta":"Hi"`) {
|
||||
t.Fatalf("missing delta text: %s", s)
|
||||
}
|
||||
if !strings.Contains(s, `"output_text":"Hi"`) {
|
||||
t.Fatalf("missing output_text in completed: %s", s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/rose_cat707/luminary/internal/model"
|
||||
)
|
||||
|
||||
type StreamResult struct {
|
||||
StatusCode int
|
||||
TokensIn int
|
||||
TokensOut int
|
||||
Body []byte // populated for non-2xx responses
|
||||
}
|
||||
|
||||
func ForwardStreamHTTP(ctx context.Context, client *http.Client, method, url string, apiKey string, providerType model.ProviderType, body []byte, w io.Writer, flusher http.Flusher) (*StreamResult, error) {
|
||||
var reader io.Reader
|
||||
if len(body) > 0 {
|
||||
reader = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setAuthHeaders(req, apiKey, providerType)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
if flusher != nil {
|
||||
w.Write(b)
|
||||
flusher.Flush()
|
||||
}
|
||||
return &StreamResult{StatusCode: resp.StatusCode, Body: b}, nil
|
||||
}
|
||||
|
||||
result := &StreamResult{StatusCode: resp.StatusCode}
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, "data: ") {
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
if data == "[DONE]" {
|
||||
continue
|
||||
}
|
||||
tokensIn, tokensOut := parseStreamChunk([]byte(data))
|
||||
result.TokensIn += tokensIn
|
||||
result.TokensOut += tokensOut
|
||||
}
|
||||
if _, err := w.Write([]byte(line + "\n")); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
return result, scanner.Err()
|
||||
}
|
||||
|
||||
func parseStreamChunk(data []byte) (int, int) {
|
||||
var chunk struct {
|
||||
Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &chunk); err == nil && chunk.Usage.PromptTokens+chunk.Usage.CompletionTokens > 0 {
|
||||
return chunk.Usage.PromptTokens, chunk.Usage.CompletionTokens
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
func NewStreamHTTPClient() *http.Client {
|
||||
return &http.Client{} // no timeout for streams
|
||||
}
|
||||
|
||||
func IsStreamRequest(body []byte) bool {
|
||||
var payload struct {
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &payload)
|
||||
return payload.Stream
|
||||
}
|
||||
|
||||
func SupportsStreaming(endpoint EndpointType) bool {
|
||||
return endpoint == EndpointChatCompletion || endpoint == EndpointResponses
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package provider
|
||||
|
||||
import "github.com/rose_cat707/luminary/internal/model"
|
||||
|
||||
type ProviderTypeMeta struct {
|
||||
Key model.ProviderType `json:"key"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
var allProviderTypes = []ProviderTypeMeta{
|
||||
{Key: model.ProviderOpenAI, Label: "OpenAI"},
|
||||
{Key: model.ProviderAnthropic, Label: "Anthropic"},
|
||||
{Key: model.ProviderAzureOpenAI, Label: "Azure OpenAI"},
|
||||
{Key: model.ProviderOpenRouter, Label: "OpenRouter"},
|
||||
{Key: model.ProviderOllama, Label: "Ollama"},
|
||||
{Key: model.ProviderCustom, Label: "Custom (OpenAI compatible)"},
|
||||
{Key: model.ProviderComfyUI, Label: "ComfyUI"},
|
||||
}
|
||||
|
||||
func ProviderTypeLabel(t model.ProviderType) string {
|
||||
for _, m := range allProviderTypes {
|
||||
if m.Key == t {
|
||||
return m.Label
|
||||
}
|
||||
}
|
||||
return string(t)
|
||||
}
|
||||
|
||||
// TypesForCategory 各出口分类支持的协议类型
|
||||
func TypesForCategory(cat model.ProviderCategory) []ProviderTypeMeta {
|
||||
switch cat {
|
||||
case model.CategoryImage:
|
||||
return []ProviderTypeMeta{{Key: model.ProviderComfyUI, Label: "ComfyUI"}}
|
||||
case model.CategoryEmbedding:
|
||||
return filterTypes(
|
||||
model.ProviderOpenAI,
|
||||
model.ProviderAzureOpenAI,
|
||||
model.ProviderOpenRouter,
|
||||
model.ProviderOllama,
|
||||
model.ProviderCustom,
|
||||
)
|
||||
case model.CategoryRerank:
|
||||
return filterTypes(
|
||||
model.ProviderOpenAI,
|
||||
model.ProviderAzureOpenAI,
|
||||
model.ProviderOpenRouter,
|
||||
model.ProviderOllama,
|
||||
model.ProviderCustom,
|
||||
)
|
||||
default:
|
||||
return filterTypes(
|
||||
model.ProviderOpenAI,
|
||||
model.ProviderAnthropic,
|
||||
model.ProviderAzureOpenAI,
|
||||
model.ProviderOpenRouter,
|
||||
model.ProviderOllama,
|
||||
model.ProviderCustom,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func filterTypes(keys ...model.ProviderType) []ProviderTypeMeta {
|
||||
set := map[model.ProviderType]bool{}
|
||||
for _, k := range keys {
|
||||
set[k] = true
|
||||
}
|
||||
out := make([]ProviderTypeMeta, 0, len(keys))
|
||||
for _, m := range allProviderTypes {
|
||||
if set[m.Key] {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func DefaultProviderType(cat model.ProviderCategory) model.ProviderType {
|
||||
types := TypesForCategory(cat)
|
||||
if len(types) == 0 {
|
||||
return model.ProviderOpenAI
|
||||
}
|
||||
return types[0].Key
|
||||
}
|
||||
|
||||
func IsTypeValidForCategory(cat model.ProviderCategory, t model.ProviderType) bool {
|
||||
for _, m := range TypesForCategory(cat) {
|
||||
if m.Key == t {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AllProviderTypes 返回全部协议类型(用于管理台展示标签)
|
||||
func AllProviderTypes() []ProviderTypeMeta {
|
||||
return append([]ProviderTypeMeta(nil), allProviderTypes...)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/rose_cat707/luminary/internal/model"
|
||||
)
|
||||
|
||||
func TestTypesForCategory(t *testing.T) {
|
||||
llm := TypesForCategory(model.CategoryLLM)
|
||||
if len(llm) != 6 {
|
||||
t.Fatalf("llm types: got %d want 6", len(llm))
|
||||
}
|
||||
image := TypesForCategory(model.CategoryImage)
|
||||
if len(image) != 1 || image[0].Key != model.ProviderComfyUI {
|
||||
t.Fatalf("image types: %+v", image)
|
||||
}
|
||||
embedding := TypesForCategory(model.CategoryEmbedding)
|
||||
for _, m := range embedding {
|
||||
if m.Key == model.ProviderAnthropic {
|
||||
t.Fatal("anthropic should not support embedding category")
|
||||
}
|
||||
}
|
||||
if !IsTypeValidForCategory(model.CategoryRerank, model.ProviderCustom) {
|
||||
t.Fatal("custom should be valid for rerank")
|
||||
}
|
||||
if IsTypeValidForCategory(model.CategoryRerank, model.ProviderAnthropic) {
|
||||
t.Fatal("anthropic should not be valid for rerank")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user