feat: enrich task record details
This commit is contained in:
@@ -39,6 +39,7 @@ type User struct {
|
|||||||
APIKeyID string `json:"apiKeyId,omitempty"`
|
APIKeyID string `json:"apiKeyId,omitempty"`
|
||||||
APIKeySecret string `json:"apiKeySecret,omitempty"`
|
APIKeySecret string `json:"apiKeySecret,omitempty"`
|
||||||
APIKeyName string `json:"apiKeyName,omitempty"`
|
APIKeyName string `json:"apiKeyName,omitempty"`
|
||||||
|
APIKeyPrefix string `json:"apiKeyPrefix,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type contextKey string
|
type contextKey string
|
||||||
@@ -135,6 +136,7 @@ func (a *Authenticator) verifyJWT(tokenString string) (*User, error) {
|
|||||||
APIKeyID: stringClaim(claims, "apiKeyId"),
|
APIKeyID: stringClaim(claims, "apiKeyId"),
|
||||||
APIKeySecret: stringClaim(claims, "apiKeySecret"),
|
APIKeySecret: stringClaim(claims, "apiKeySecret"),
|
||||||
APIKeyName: stringClaim(claims, "apiKeyName"),
|
APIKeyName: stringClaim(claims, "apiKeyName"),
|
||||||
|
APIKeyPrefix: stringClaim(claims, "apiKeyPrefix"),
|
||||||
}
|
}
|
||||||
if user.Source == "" {
|
if user.Source == "" {
|
||||||
user.Source = "gateway"
|
user.Source = "gateway"
|
||||||
@@ -162,6 +164,9 @@ func (a *Authenticator) SignJWT(user *User, ttl time.Duration) (string, error) {
|
|||||||
"userGroupId": user.UserGroupID,
|
"userGroupId": user.UserGroupID,
|
||||||
"userGroupKey": user.UserGroupKey,
|
"userGroupKey": user.UserGroupKey,
|
||||||
"userGroupKeys": user.UserGroupKeys,
|
"userGroupKeys": user.UserGroupKeys,
|
||||||
|
"apiKeyId": user.APIKeyID,
|
||||||
|
"apiKeyName": user.APIKeyName,
|
||||||
|
"apiKeyPrefix": user.APIKeyPrefix,
|
||||||
"iat": now.Unix(),
|
"iat": now.Unix(),
|
||||||
"exp": now.Add(ttl).Unix(),
|
"exp": now.Add(ttl).Unix(),
|
||||||
}
|
}
|
||||||
@@ -235,6 +240,31 @@ func hasPermission(roles []string, required Permission) bool {
|
|||||||
return granted[required]
|
return granted[required]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func PermissionLevel(roles []string) int {
|
||||||
|
level := 0
|
||||||
|
for _, role := range roles {
|
||||||
|
switch role {
|
||||||
|
case "admin", "manager":
|
||||||
|
if level < 4 {
|
||||||
|
level = 4
|
||||||
|
}
|
||||||
|
case "operator":
|
||||||
|
if level < 3 {
|
||||||
|
level = 3
|
||||||
|
}
|
||||||
|
case "creator":
|
||||||
|
if level < 2 {
|
||||||
|
level = 2
|
||||||
|
}
|
||||||
|
case "user":
|
||||||
|
if level < 1 {
|
||||||
|
level = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return level
|
||||||
|
}
|
||||||
|
|
||||||
func permissionsForRole(role string) []Permission {
|
func permissionsForRole(role string) []Permission {
|
||||||
switch role {
|
switch role {
|
||||||
case "admin", "manager":
|
case "admin", "manager":
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ func TestOpenAIClientChatContract(t *testing.T) {
|
|||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
gotPath = r.URL.Path
|
gotPath = r.URL.Path
|
||||||
gotAuth = r.Header.Get("Authorization")
|
gotAuth = r.Header.Get("Authorization")
|
||||||
|
w.Header().Set("X-Request-Id", "req-chat-test")
|
||||||
var body map[string]any
|
var body map[string]any
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
t.Fatalf("decode request: %v", err)
|
t.Fatalf("decode request: %v", err)
|
||||||
@@ -53,6 +54,55 @@ func TestOpenAIClientChatContract(t *testing.T) {
|
|||||||
if response.Usage.TotalTokens != 5 || response.Result["id"] != "chatcmpl-test" {
|
if response.Usage.TotalTokens != 5 || response.Result["id"] != "chatcmpl-test" {
|
||||||
t.Fatalf("unexpected response: %+v", response)
|
t.Fatalf("unexpected response: %+v", response)
|
||||||
}
|
}
|
||||||
|
if response.RequestID != "req-chat-test" || response.ResponseStartedAt.IsZero() || response.ResponseFinishedAt.IsZero() {
|
||||||
|
t.Fatalf("response metadata was not captured: %+v", response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAIClientChatStreamContract(t *testing.T) {
|
||||||
|
var gotStream bool
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
t.Fatalf("decode request: %v", err)
|
||||||
|
}
|
||||||
|
gotStream, _ = body["stream"].(bool)
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-stream\",\"object\":\"chat.completion.chunk\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n"))
|
||||||
|
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-stream\",\"object\":\"chat.completion.chunk\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"delta\":{\"content\":\" world\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":2,\"total_tokens\":3}}\n\n"))
|
||||||
|
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
response, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
|
||||||
|
Kind: "chat.completions",
|
||||||
|
Model: "DeepSeek-V4-Flash",
|
||||||
|
Body: map[string]any{
|
||||||
|
"model": "DeepSeek-V4-Flash",
|
||||||
|
"messages": []any{map[string]any{"role": "user", "content": "ping"}},
|
||||||
|
"stream": true,
|
||||||
|
},
|
||||||
|
Candidate: store.RuntimeModelCandidate{
|
||||||
|
BaseURL: server.URL,
|
||||||
|
ModelName: "deepseek-v4-flash",
|
||||||
|
Credentials: map[string]any{"apiKey": "test-key"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run openai stream client: %v", err)
|
||||||
|
}
|
||||||
|
if !gotStream {
|
||||||
|
t.Fatalf("expected upstream stream request")
|
||||||
|
}
|
||||||
|
if response.Usage.TotalTokens != 3 {
|
||||||
|
t.Fatalf("unexpected usage: %+v", response.Usage)
|
||||||
|
}
|
||||||
|
choices, _ := response.Result["choices"].([]any)
|
||||||
|
choice, _ := choices[0].(map[string]any)
|
||||||
|
message, _ := choice["message"].(map[string]any)
|
||||||
|
if message["content"] != "hello world" {
|
||||||
|
t.Fatalf("unexpected stream response: %+v", response.Result)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGeminiClientChatContract(t *testing.T) {
|
func TestGeminiClientChatContract(t *testing.T) {
|
||||||
@@ -111,6 +161,14 @@ func TestGeminiClientChatContract(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGeminiURLAcceptsVersionedBaseURL(t *testing.T) {
|
||||||
|
got := geminiURL("https://generativelanguage.googleapis.com/v1beta", "gemini-2.5-flash", "test-key")
|
||||||
|
want := "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=test-key"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("unexpected gemini url: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func extractText(result map[string]any) string {
|
func extractText(result map[string]any) string {
|
||||||
choices, _ := result["choices"].([]any)
|
choices, _ := result["choices"].([]any)
|
||||||
choice, _ := choices[0].(map[string]any)
|
choice, _ := choices[0].(map[string]any)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type GeminiClient struct {
|
type GeminiClient struct {
|
||||||
@@ -30,11 +31,26 @@ func (c GeminiClient) Run(ctx context.Context, request Request) (Response, error
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Response{}, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
return Response{}, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
||||||
}
|
}
|
||||||
|
responseStartedAt := time.Now()
|
||||||
|
requestID := requestIDFromHTTPResponse(resp)
|
||||||
result, err := decodeHTTPResponse(resp)
|
result, err := decodeHTTPResponse(resp)
|
||||||
|
responseFinishedAt := time.Now()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Response{}, err
|
return Response{}, annotateResponseError(err, requestID, responseStartedAt, responseFinishedAt)
|
||||||
}
|
}
|
||||||
return Response{Result: geminiResult(request, result), Usage: geminiUsage(result), Progress: providerProgress(request)}, nil
|
output := geminiResult(request, result)
|
||||||
|
if requestID == "" {
|
||||||
|
requestID = requestIDFromResult(output)
|
||||||
|
}
|
||||||
|
return Response{
|
||||||
|
Result: output,
|
||||||
|
RequestID: requestID,
|
||||||
|
Usage: geminiUsage(result),
|
||||||
|
Progress: providerProgress(request),
|
||||||
|
ResponseStartedAt: responseStartedAt,
|
||||||
|
ResponseFinishedAt: responseFinishedAt,
|
||||||
|
ResponseDurationMS: responseDurationMS(responseStartedAt, responseFinishedAt),
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func geminiURL(baseURL string, model string, apiKey string) string {
|
func geminiURL(baseURL string, model string, apiKey string) string {
|
||||||
@@ -42,6 +58,9 @@ func geminiURL(baseURL string, model string, apiKey string) string {
|
|||||||
if base == "" {
|
if base == "" {
|
||||||
base = "https://generativelanguage.googleapis.com"
|
base = "https://generativelanguage.googleapis.com"
|
||||||
}
|
}
|
||||||
|
if strings.HasSuffix(base, "/v1beta") {
|
||||||
|
base = strings.TrimSuffix(base, "/v1beta")
|
||||||
|
}
|
||||||
escapedModel := url.PathEscape(model)
|
escapedModel := url.PathEscape(model)
|
||||||
return fmt.Sprintf("%s/v1beta/models/%s:generateContent?key=%s", base, escapedModel, url.QueryEscape(apiKey))
|
return fmt.Sprintf("%s/v1beta/models/%s:generateContent?key=%s", base, escapedModel, url.QueryEscape(apiKey))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package clients
|
package clients
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -48,6 +50,7 @@ func decodeHTTPResponse(resp *http.Response) (map[string]any, error) {
|
|||||||
Code: statusCodeName(resp.StatusCode),
|
Code: statusCodeName(resp.StatusCode),
|
||||||
Message: errorMessage(raw, resp.Status),
|
Message: errorMessage(raw, resp.Status),
|
||||||
StatusCode: resp.StatusCode,
|
StatusCode: resp.StatusCode,
|
||||||
|
RequestID: requestIDFromHTTPResponse(resp),
|
||||||
Retryable: HTTPRetryable(resp.StatusCode),
|
Retryable: HTTPRetryable(resp.StatusCode),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,6 +64,137 @@ func decodeHTTPResponse(resp *http.Response) (map[string]any, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func decodeOpenAIStreamResponse(resp *http.Response, onDelta StreamDelta) (map[string]any, error) {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024*1024))
|
||||||
|
return nil, &ClientError{
|
||||||
|
Code: statusCodeName(resp.StatusCode),
|
||||||
|
Message: errorMessage(raw, resp.Status),
|
||||||
|
StatusCode: resp.StatusCode,
|
||||||
|
RequestID: requestIDFromHTTPResponse(resp),
|
||||||
|
Retryable: HTTPRetryable(resp.StatusCode),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if result, ok, err := decodeOpenAIStreamReader(resp.Body, onDelta); ok || err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
return map[string]any{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeOpenAIStreamReader(reader io.Reader, onDelta StreamDelta) (map[string]any, bool, error) {
|
||||||
|
scanner := bufio.NewScanner(reader)
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
|
||||||
|
rawLines := make([]string, 0)
|
||||||
|
parts := make([]string, 0)
|
||||||
|
var last map[string]any
|
||||||
|
var usage Usage
|
||||||
|
for scanner.Scan() {
|
||||||
|
rawLine := scanner.Text()
|
||||||
|
rawLines = append(rawLines, rawLine)
|
||||||
|
line := strings.TrimSpace(rawLine)
|
||||||
|
if !strings.HasPrefix(line, "data:") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||||
|
if payload == "" || payload == "[DONE]" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var event map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(payload), &event); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
last = event
|
||||||
|
if text := streamEventText(event); text != "" {
|
||||||
|
parts = append(parts, text)
|
||||||
|
if onDelta != nil {
|
||||||
|
if err := onDelta(text); err != nil {
|
||||||
|
return nil, true, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if eventUsage := usageFromOpenAI(event); eventUsage.TotalTokens > 0 {
|
||||||
|
usage = eventUsage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return nil, true, &ClientError{Code: "stream_read_error", Message: err.Error(), Retryable: true}
|
||||||
|
}
|
||||||
|
if last == nil {
|
||||||
|
raw := []byte(strings.Join(rawLines, "\n"))
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return map[string]any{}, true, nil
|
||||||
|
}
|
||||||
|
var out map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
return out, true, nil
|
||||||
|
}
|
||||||
|
return buildOpenAIStreamResult(last, parts, usage), true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeOpenAIStream(raw []byte) (map[string]any, bool) {
|
||||||
|
if !bytes.Contains(raw, []byte("data:")) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
result, ok, err := decodeOpenAIStreamReader(bytes.NewReader(raw), nil)
|
||||||
|
return result, ok && err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildOpenAIStreamResult(last map[string]any, parts []string, usage Usage) map[string]any {
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return last
|
||||||
|
}
|
||||||
|
var out map[string]any
|
||||||
|
out = map[string]any{
|
||||||
|
"id": stringFromAny(firstPresent(last["id"], "chatcmpl-stream")),
|
||||||
|
"object": "chat.completion",
|
||||||
|
"model": stringFromAny(last["model"]),
|
||||||
|
"choices": []any{map[string]any{
|
||||||
|
"index": 0,
|
||||||
|
"message": map[string]any{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": strings.Join(parts, ""),
|
||||||
|
},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if usage.TotalTokens > 0 {
|
||||||
|
out["usage"] = map[string]any{
|
||||||
|
"prompt_tokens": usage.InputTokens,
|
||||||
|
"completion_tokens": usage.OutputTokens,
|
||||||
|
"total_tokens": usage.TotalTokens,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func streamEventText(event map[string]any) string {
|
||||||
|
if choices, ok := event["choices"].([]any); ok {
|
||||||
|
for _, rawChoice := range choices {
|
||||||
|
choice, _ := rawChoice.(map[string]any)
|
||||||
|
if delta, ok := choice["delta"].(map[string]any); ok {
|
||||||
|
if content, ok := delta["content"].(string); ok {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if message, ok := choice["message"].(map[string]any); ok {
|
||||||
|
if content, ok := message["content"].(string); ok {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if delta, ok := event["delta"].(string); ok {
|
||||||
|
return delta
|
||||||
|
}
|
||||||
|
if text, ok := event["output_text"].(string); ok {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func usageFromOpenAI(result map[string]any) Usage {
|
func usageFromOpenAI(result map[string]any) Usage {
|
||||||
usage, _ := result["usage"].(map[string]any)
|
usage, _ := result["usage"].(map[string]any)
|
||||||
input := intFromAny(firstPresent(usage["prompt_tokens"], usage["input_tokens"]))
|
input := intFromAny(firstPresent(usage["prompt_tokens"], usage["input_tokens"]))
|
||||||
@@ -72,6 +206,34 @@ func usageFromOpenAI(result map[string]any) Usage {
|
|||||||
return Usage{InputTokens: input, OutputTokens: output, TotalTokens: total}
|
return Usage{InputTokens: input, OutputTokens: output, TotalTokens: total}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func requestIDFromHTTPResponse(resp *http.Response) string {
|
||||||
|
if resp == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, key := range []string{
|
||||||
|
"x-request-id",
|
||||||
|
"x-requestid",
|
||||||
|
"request-id",
|
||||||
|
"x-amzn-requestid",
|
||||||
|
"x-amz-request-id",
|
||||||
|
"cf-ray",
|
||||||
|
} {
|
||||||
|
if value := strings.TrimSpace(resp.Header.Get(key)); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestIDFromResult(result map[string]any) string {
|
||||||
|
for _, key := range []string{"request_id", "requestId", "id", "response_id", "responseId"} {
|
||||||
|
if value := strings.TrimSpace(stringFromAny(result[key])); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func intFromAny(value any) int {
|
func intFromAny(value any) int {
|
||||||
switch typed := value.(type) {
|
switch typed := value.(type) {
|
||||||
case float64:
|
case float64:
|
||||||
@@ -85,6 +247,13 @@ func intFromAny(value any) int {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func stringFromAny(value any) string {
|
||||||
|
if text, ok := value.(string); ok {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func firstPresent(values ...any) any {
|
func firstPresent(values ...any) any {
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
if value != nil {
|
if value != nil {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type OpenAIClient struct {
|
type OpenAIClient struct {
|
||||||
@@ -23,6 +24,7 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
|
|||||||
}
|
}
|
||||||
body := cloneBody(request.Body)
|
body := cloneBody(request.Body)
|
||||||
body["model"] = request.Candidate.ModelName
|
body["model"] = request.Candidate.ModelName
|
||||||
|
stream := request.Stream || boolValue(body, "stream")
|
||||||
raw, _ := json.Marshal(body)
|
raw, _ := json.Marshal(body)
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinURL(request.Candidate.BaseURL, endpoint), bytes.NewReader(raw))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinURL(request.Candidate.BaseURL, endpoint), bytes.NewReader(raw))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -34,11 +36,36 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Response{}, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
return Response{}, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
||||||
}
|
}
|
||||||
result, err := decodeHTTPResponse(resp)
|
responseStartedAt := time.Now()
|
||||||
|
requestID := requestIDFromHTTPResponse(resp)
|
||||||
|
result, err := decodeOpenAIResponse(resp, stream, request.StreamDelta)
|
||||||
|
responseFinishedAt := time.Now()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Response{}, err
|
return Response{}, annotateResponseError(err, requestID, responseStartedAt, responseFinishedAt)
|
||||||
}
|
}
|
||||||
return Response{Result: result, Usage: usageFromOpenAI(result), Progress: providerProgress(request)}, nil
|
if requestID == "" {
|
||||||
|
requestID = requestIDFromResult(result)
|
||||||
|
}
|
||||||
|
return Response{
|
||||||
|
Result: result,
|
||||||
|
RequestID: requestID,
|
||||||
|
Usage: usageFromOpenAI(result),
|
||||||
|
Progress: providerProgress(request),
|
||||||
|
ResponseStartedAt: responseStartedAt,
|
||||||
|
ResponseFinishedAt: responseFinishedAt,
|
||||||
|
ResponseDurationMS: responseDurationMS(responseStartedAt, responseFinishedAt),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeOpenAIResponse(resp *http.Response, stream bool, onDelta StreamDelta) (map[string]any, error) {
|
||||||
|
if stream {
|
||||||
|
result, err := decodeOpenAIStreamResponse(resp, onDelta)
|
||||||
|
if err == nil {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return decodeHTTPResponse(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
func openAIEndpoint(kind string) string {
|
func openAIEndpoint(kind string) string {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SimulationClient struct{}
|
type SimulationClient struct{}
|
||||||
@@ -17,10 +18,17 @@ func (c SimulationClient) Run(ctx context.Context, request Request) (Response, e
|
|||||||
if profile == "fatal_failure" || profile == "non_retryable_failure" {
|
if profile == "fatal_failure" || profile == "non_retryable_failure" {
|
||||||
return Response{}, &ClientError{Code: "bad_request", Message: "simulated non-retryable failure", Retryable: false}
|
return Response{}, &ClientError{Code: "bad_request", Message: "simulated non-retryable failure", Retryable: false}
|
||||||
}
|
}
|
||||||
|
responseStartedAt := time.Now()
|
||||||
|
result := simulatedResult(request)
|
||||||
|
responseFinishedAt := time.Now()
|
||||||
return Response{
|
return Response{
|
||||||
Result: simulatedResult(request),
|
Result: result,
|
||||||
Usage: simulatedUsage(request),
|
RequestID: requestIDFromResult(result),
|
||||||
Progress: simulatedProgress(request),
|
Usage: simulatedUsage(request),
|
||||||
|
Progress: simulatedProgress(request),
|
||||||
|
ResponseStartedAt: responseStartedAt,
|
||||||
|
ResponseFinishedAt: responseFinishedAt,
|
||||||
|
ResponseDurationMS: responseDurationMS(responseStartedAt, responseFinishedAt),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,23 +4,30 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Request struct {
|
type Request struct {
|
||||||
Kind string
|
Kind string
|
||||||
ModelType string
|
ModelType string
|
||||||
Model string
|
Model string
|
||||||
Body map[string]any
|
Body map[string]any
|
||||||
Candidate store.RuntimeModelCandidate
|
Candidate store.RuntimeModelCandidate
|
||||||
Stream bool
|
Stream bool
|
||||||
|
StreamDelta StreamDelta
|
||||||
}
|
}
|
||||||
|
|
||||||
type Response struct {
|
type Response struct {
|
||||||
Result map[string]any
|
Result map[string]any
|
||||||
Usage Usage
|
RequestID string
|
||||||
Progress []Progress
|
Usage Usage
|
||||||
|
Progress []Progress
|
||||||
|
Metrics map[string]any
|
||||||
|
ResponseStartedAt time.Time
|
||||||
|
ResponseFinishedAt time.Time
|
||||||
|
ResponseDurationMS int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type Usage struct {
|
type Usage struct {
|
||||||
@@ -36,15 +43,21 @@ type Progress struct {
|
|||||||
Payload map[string]any
|
Payload map[string]any
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StreamDelta func(text string) error
|
||||||
|
|
||||||
type Client interface {
|
type Client interface {
|
||||||
Run(ctx context.Context, request Request) (Response, error)
|
Run(ctx context.Context, request Request) (Response, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ClientError struct {
|
type ClientError struct {
|
||||||
Code string
|
Code string
|
||||||
Message string
|
Message string
|
||||||
StatusCode int
|
StatusCode int
|
||||||
Retryable bool
|
RequestID string
|
||||||
|
ResponseStartedAt time.Time
|
||||||
|
ResponseFinishedAt time.Time
|
||||||
|
ResponseDurationMS int64
|
||||||
|
Retryable bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *ClientError) Error() string {
|
func (e *ClientError) Error() string {
|
||||||
@@ -67,6 +80,59 @@ func ErrorCode(err error) string {
|
|||||||
return "client_error"
|
return "client_error"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ResponseMetadata struct {
|
||||||
|
RequestID string
|
||||||
|
ResponseStartedAt time.Time
|
||||||
|
ResponseFinishedAt time.Time
|
||||||
|
ResponseDurationMS int64
|
||||||
|
StatusCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
func ErrorResponseMetadata(err error) ResponseMetadata {
|
||||||
|
var clientErr *ClientError
|
||||||
|
if errors.As(err, &clientErr) {
|
||||||
|
return ResponseMetadata{
|
||||||
|
RequestID: clientErr.RequestID,
|
||||||
|
ResponseStartedAt: clientErr.ResponseStartedAt,
|
||||||
|
ResponseFinishedAt: clientErr.ResponseFinishedAt,
|
||||||
|
ResponseDurationMS: clientErr.ResponseDurationMS,
|
||||||
|
StatusCode: clientErr.StatusCode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ResponseMetadata{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func annotateResponseError(err error, requestID string, startedAt time.Time, finishedAt time.Time) error {
|
||||||
|
var clientErr *ClientError
|
||||||
|
if !errors.As(err, &clientErr) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if clientErr.RequestID == "" {
|
||||||
|
clientErr.RequestID = requestID
|
||||||
|
}
|
||||||
|
if clientErr.ResponseStartedAt.IsZero() {
|
||||||
|
clientErr.ResponseStartedAt = startedAt
|
||||||
|
}
|
||||||
|
if clientErr.ResponseFinishedAt.IsZero() {
|
||||||
|
clientErr.ResponseFinishedAt = finishedAt
|
||||||
|
}
|
||||||
|
if clientErr.ResponseDurationMS == 0 {
|
||||||
|
clientErr.ResponseDurationMS = responseDurationMS(clientErr.ResponseStartedAt, clientErr.ResponseFinishedAt)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func HTTPRetryable(status int) bool {
|
func HTTPRetryable(status int) bool {
|
||||||
return status == http.StatusTooManyRequests || status == http.StatusRequestTimeout || status >= 500
|
return status == http.StatusTooManyRequests || status == http.StatusRequestTimeout || status >= 500
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func responseDurationMS(startedAt time.Time, finishedAt time.Time) int64 {
|
||||||
|
if startedAt.IsZero() || finishedAt.IsZero() {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
duration := finishedAt.Sub(startedAt).Milliseconds()
|
||||||
|
if duration < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return duration
|
||||||
|
}
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ func TestCoreLocalFlow(t *testing.T) {
|
|||||||
Secret string `json:"secret"`
|
Secret string `json:"secret"`
|
||||||
APIKey struct {
|
APIKey struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
KeyPrefix string `json:"keyPrefix"`
|
KeyPrefix string `json:"keyPrefix"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
} `json:"apiKey"`
|
} `json:"apiKey"`
|
||||||
@@ -187,10 +188,10 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
|
|||||||
|
|
||||||
var baseModels struct {
|
var baseModels struct {
|
||||||
Items []struct {
|
Items []struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
CanonicalModelKey string `json:"canonicalModelKey"`
|
CanonicalModelKey string `json:"canonicalModelKey"`
|
||||||
ProviderModelName string `json:"providerModelName"`
|
ProviderModelName string `json:"providerModelName"`
|
||||||
ModelType string `json:"modelType"`
|
ModelType []string `json:"modelType"`
|
||||||
} `json:"items"`
|
} `json:"items"`
|
||||||
}
|
}
|
||||||
doJSON(t, server.URL, http.MethodGet, "/api/v1/catalog/base-models", apiKeyResponse.Secret, nil, http.StatusOK, &baseModels)
|
doJSON(t, server.URL, http.MethodGet, "/api/v1/catalog/base-models", apiKeyResponse.Secret, nil, http.StatusOK, &baseModels)
|
||||||
@@ -202,36 +203,47 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
|
|||||||
"providerKey": "openai",
|
"providerKey": "openai",
|
||||||
"canonicalModelKey": "openai:smoke-base-" + suffixText,
|
"canonicalModelKey": "openai:smoke-base-" + suffixText,
|
||||||
"providerModelName": "smoke-base-" + suffixText,
|
"providerModelName": "smoke-base-" + suffixText,
|
||||||
"modelType": "text_generate",
|
"modelType": []string{"text_generate"},
|
||||||
"displayName": "Smoke Base Model",
|
"modelAlias": "Smoke Base Model",
|
||||||
"capabilities": map[string]any{"originalTypes": []string{"text_generate"}},
|
"capabilities": map[string]any{"originalTypes": []string{"text_generate"}},
|
||||||
"metadata": map[string]any{"source": "test"},
|
"metadata": map[string]any{"source": "test"},
|
||||||
}
|
}
|
||||||
var createdBaseModel struct {
|
var createdBaseModel struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
CanonicalModelKey string `json:"canonicalModelKey"`
|
CanonicalModelKey string `json:"canonicalModelKey"`
|
||||||
DisplayName string `json:"displayName"`
|
ModelAlias string `json:"modelAlias"`
|
||||||
}
|
}
|
||||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/catalog/base-models", apiKeyResponse.Secret, baseModelInput, http.StatusCreated, &createdBaseModel)
|
doJSON(t, server.URL, http.MethodPost, "/api/v1/catalog/base-models", apiKeyResponse.Secret, baseModelInput, http.StatusCreated, &createdBaseModel)
|
||||||
if createdBaseModel.ID == "" || createdBaseModel.CanonicalModelKey != baseModelInput["canonicalModelKey"] {
|
if createdBaseModel.ID == "" || createdBaseModel.CanonicalModelKey != baseModelInput["canonicalModelKey"] {
|
||||||
t.Fatalf("unexpected created base model: %+v", createdBaseModel)
|
t.Fatalf("unexpected created base model: %+v", createdBaseModel)
|
||||||
}
|
}
|
||||||
baseModelInput["displayName"] = "Smoke Base Model Updated"
|
baseModelInput["modelAlias"] = "Smoke Base Model Updated"
|
||||||
var updatedBaseModel struct {
|
var updatedBaseModel struct {
|
||||||
DisplayName string `json:"displayName"`
|
ModelAlias string `json:"modelAlias"`
|
||||||
}
|
}
|
||||||
doJSON(t, server.URL, http.MethodPatch, "/api/v1/catalog/base-models/"+createdBaseModel.ID, apiKeyResponse.Secret, baseModelInput, http.StatusOK, &updatedBaseModel)
|
doJSON(t, server.URL, http.MethodPatch, "/api/v1/catalog/base-models/"+createdBaseModel.ID, apiKeyResponse.Secret, baseModelInput, http.StatusOK, &updatedBaseModel)
|
||||||
if updatedBaseModel.DisplayName != "Smoke Base Model Updated" {
|
if updatedBaseModel.ModelAlias != "Smoke Base Model Updated" {
|
||||||
t.Fatalf("unexpected updated base model: %+v", updatedBaseModel)
|
t.Fatalf("unexpected updated base model: %+v", updatedBaseModel)
|
||||||
}
|
}
|
||||||
doJSON(t, server.URL, http.MethodDelete, "/api/v1/catalog/base-models/"+createdBaseModel.ID, apiKeyResponse.Secret, nil, http.StatusNoContent, nil)
|
doJSON(t, server.URL, http.MethodDelete, "/api/v1/catalog/base-models/"+createdBaseModel.ID, apiKeyResponse.Secret, nil, http.StatusNoContent, nil)
|
||||||
|
|
||||||
var taskResponse struct {
|
var taskResponse struct {
|
||||||
Task struct {
|
Task struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
RunMode string `json:"runMode"`
|
RunMode string `json:"runMode"`
|
||||||
Result map[string]any `json:"result"`
|
APIKeyID string `json:"apiKeyId"`
|
||||||
|
APIKeyName string `json:"apiKeyName"`
|
||||||
|
RequestID string `json:"requestId"`
|
||||||
|
ResolvedModel string `json:"resolvedModel"`
|
||||||
|
Usage map[string]any `json:"usage"`
|
||||||
|
Metrics map[string]any `json:"metrics"`
|
||||||
|
BillingSummary map[string]any `json:"billingSummary"`
|
||||||
|
FinalChargeAmount float64 `json:"finalChargeAmount"`
|
||||||
|
ResponseStartedAt string `json:"responseStartedAt"`
|
||||||
|
ResponseFinishedAt string `json:"responseFinishedAt"`
|
||||||
|
ResponseDurationMS int64 `json:"responseDurationMs"`
|
||||||
|
Result map[string]any `json:"result"`
|
||||||
} `json:"task"`
|
} `json:"task"`
|
||||||
}
|
}
|
||||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/chat/completions", apiKeyResponse.Secret, map[string]any{
|
doJSON(t, server.URL, http.MethodPost, "/api/v1/chat/completions", apiKeyResponse.Secret, map[string]any{
|
||||||
@@ -243,6 +255,18 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
|
|||||||
if taskResponse.Task.ID == "" || taskResponse.Task.Status != "succeeded" || taskResponse.Task.RunMode != "simulation" {
|
if taskResponse.Task.ID == "" || taskResponse.Task.Status != "succeeded" || taskResponse.Task.RunMode != "simulation" {
|
||||||
t.Fatalf("unexpected task response: %+v", taskResponse.Task)
|
t.Fatalf("unexpected task response: %+v", taskResponse.Task)
|
||||||
}
|
}
|
||||||
|
if taskResponse.Task.APIKeyID != apiKeyResponse.APIKey.ID || taskResponse.Task.APIKeyName != apiKeyResponse.APIKey.Name {
|
||||||
|
t.Fatalf("task should record full api key identity: %+v key=%+v", taskResponse.Task, apiKeyResponse.APIKey)
|
||||||
|
}
|
||||||
|
if taskResponse.Task.RequestID == "" || taskResponse.Task.ResolvedModel == "" || taskResponse.Task.ResponseStartedAt == "" || taskResponse.Task.ResponseFinishedAt == "" {
|
||||||
|
t.Fatalf("task should record provider request and response timing: %+v", taskResponse.Task)
|
||||||
|
}
|
||||||
|
if taskResponse.Task.Usage["totalTokens"] == nil || taskResponse.Task.FinalChargeAmount <= 0 {
|
||||||
|
t.Fatalf("task should record token usage and final charge: %+v", taskResponse.Task)
|
||||||
|
}
|
||||||
|
if taskResponse.Task.BillingSummary["finalCharge"] == nil || taskResponse.Task.Metrics["requestedModel"] == nil {
|
||||||
|
t.Fatalf("task should record billing summary and task metrics: %+v", taskResponse.Task)
|
||||||
|
}
|
||||||
|
|
||||||
var compatChat map[string]any
|
var compatChat map[string]any
|
||||||
doJSON(t, server.URL, http.MethodPost, "/v1/chat/completions", apiKeyResponse.Secret, map[string]any{
|
doJSON(t, server.URL, http.MethodPost, "/v1/chat/completions", apiKeyResponse.Secret, map[string]any{
|
||||||
@@ -348,14 +372,22 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var taskDetail struct {
|
var taskDetail struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Result map[string]any `json:"result"`
|
APIKeyName string `json:"apiKeyName"`
|
||||||
|
RequestID string `json:"requestId"`
|
||||||
|
Usage map[string]any `json:"usage"`
|
||||||
|
BillingSummary map[string]any `json:"billingSummary"`
|
||||||
|
FinalChargeAmount float64 `json:"finalChargeAmount"`
|
||||||
|
Result map[string]any `json:"result"`
|
||||||
}
|
}
|
||||||
doJSON(t, server.URL, http.MethodGet, "/api/v1/tasks/"+taskResponse.Task.ID, apiKeyResponse.Secret, nil, http.StatusOK, &taskDetail)
|
doJSON(t, server.URL, http.MethodGet, "/api/v1/tasks/"+taskResponse.Task.ID, apiKeyResponse.Secret, nil, http.StatusOK, &taskDetail)
|
||||||
if taskDetail.Status != "succeeded" || taskDetail.Result["id"] == "" {
|
if taskDetail.Status != "succeeded" || taskDetail.Result["id"] == "" {
|
||||||
t.Fatalf("unexpected task detail: %+v", taskDetail)
|
t.Fatalf("unexpected task detail: %+v", taskDetail)
|
||||||
}
|
}
|
||||||
|
if taskDetail.APIKeyName != apiKeyResponse.APIKey.Name || taskDetail.RequestID == "" || taskDetail.Usage["totalTokens"] == nil || taskDetail.FinalChargeAmount <= 0 {
|
||||||
|
t.Fatalf("task detail should expose enriched record fields: %+v", taskDetail)
|
||||||
|
}
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodGet, server.URL+"/api/v1/tasks/"+taskResponse.Task.ID+"/events", nil)
|
req, err := http.NewRequest(http.MethodGet, server.URL+"/api/v1/tasks/"+taskResponse.Task.ID+"/events", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ func stringFromMap(values map[string]any, key string) string {
|
|||||||
return strings.TrimSpace(value)
|
return strings.TrimSpace(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func stringFromAny(value any) string {
|
||||||
|
text, _ := value.(string)
|
||||||
|
return strings.TrimSpace(text)
|
||||||
|
}
|
||||||
|
|
||||||
func boolFromMap(values map[string]any, key string) bool {
|
func boolFromMap(values map[string]any, key string) bool {
|
||||||
value, _ := values[key].(bool)
|
value, _ := values[key].(bool)
|
||||||
return value
|
return value
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ type EstimateResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Estimate(ctx context.Context, kind string, model string, body map[string]any, user *auth.User) (EstimateResult, error) {
|
func (s *Service) Estimate(ctx context.Context, kind string, model string, body map[string]any, user *auth.User) (EstimateResult, error) {
|
||||||
candidates, err := s.store.ListModelCandidates(ctx, model, modelTypeFromKind(kind))
|
candidates, err := s.store.ListModelCandidates(ctx, model, modelTypeFromKind(kind), user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return EstimateResult{}, err
|
return EstimateResult{}, err
|
||||||
}
|
}
|
||||||
@@ -40,15 +40,15 @@ func (s *Service) Estimate(ctx context.Context, kind string, model string, body
|
|||||||
func (s *Service) billings(ctx context.Context, user *auth.User, kind string, body map[string]any, candidate store.RuntimeModelCandidate, response clients.Response, simulated bool) []any {
|
func (s *Service) billings(ctx context.Context, user *auth.User, kind string, body map[string]any, candidate store.RuntimeModelCandidate, response clients.Response, simulated bool) []any {
|
||||||
config := effectiveBillingConfig(candidate)
|
config := effectiveBillingConfig(candidate)
|
||||||
discount := effectiveDiscount(ctx, s.store, user, candidate)
|
discount := effectiveDiscount(ctx, s.store, user, candidate)
|
||||||
if modelTypeFromKind(kind) == "chat" {
|
if isTextGenerationKind(kind) {
|
||||||
inputTokens := response.Usage.InputTokens
|
inputTokens := response.Usage.InputTokens
|
||||||
outputTokens := response.Usage.OutputTokens
|
outputTokens := response.Usage.OutputTokens
|
||||||
if inputTokens == 0 && outputTokens == 0 {
|
if inputTokens == 0 && outputTokens == 0 {
|
||||||
inputTokens = estimateRequestTokens(body)
|
inputTokens = estimateRequestTokens(body)
|
||||||
outputTokens = 1
|
outputTokens = 1
|
||||||
}
|
}
|
||||||
inputAmount := roundPrice(float64(inputTokens) / 1000 * price(config, "textInputPer1k") * discount)
|
inputAmount := roundPrice(float64(inputTokens) / 1000 * resourcePrice(config, "text", "textInputPer1k", "inputTokenPrice", "basePrice") * discount)
|
||||||
outputAmount := roundPrice(float64(outputTokens) / 1000 * price(config, "textOutputPer1k") * discount)
|
outputAmount := roundPrice(float64(outputTokens) / 1000 * resourcePrice(config, "text", "textOutputPer1k", "outputTokenPrice", "basePrice") * discount)
|
||||||
return []any{
|
return []any{
|
||||||
billingLine(candidate, "text_input", "1k_tokens", inputTokens, inputAmount, discount, simulated),
|
billingLine(candidate, "text_input", "1k_tokens", inputTokens, inputAmount, discount, simulated),
|
||||||
billingLine(candidate, "text_output", "1k_tokens", outputTokens, outputAmount, discount, simulated),
|
billingLine(candidate, "text_output", "1k_tokens", outputTokens, outputAmount, discount, simulated),
|
||||||
@@ -59,13 +59,19 @@ func (s *Service) billings(ctx context.Context, user *auth.User, kind string, bo
|
|||||||
count = 1
|
count = 1
|
||||||
}
|
}
|
||||||
resource := "image"
|
resource := "image"
|
||||||
|
unit := "image"
|
||||||
baseKey := "imageBase"
|
baseKey := "imageBase"
|
||||||
if kind == "images.edits" {
|
if kind == "images.edits" {
|
||||||
resource = "image_edit"
|
resource = "image_edit"
|
||||||
baseKey = "editBase"
|
baseKey = "editBase"
|
||||||
}
|
}
|
||||||
amount := float64(count) * price(config, baseKey) * weighted(config, "qualityWeights", stringFromMap(body, "quality")) * weighted(config, "sizeWeights", stringFromMap(body, "size")) * discount
|
if kind == "videos.generations" {
|
||||||
return []any{billingLine(candidate, resource, "image", count, roundPrice(amount), discount, simulated)}
|
resource = "video"
|
||||||
|
unit = "video"
|
||||||
|
baseKey = "videoBase"
|
||||||
|
}
|
||||||
|
amount := float64(count) * resourcePrice(config, resource, baseKey, "basePrice") * resourceWeight(config, resource, "qualityWeights", stringFromMap(body, "quality")) * resourceWeight(config, resource, "sizeWeights", stringFromMap(body, "size")) * resourceWeight(config, resource, "resolutionWeights", firstNonEmptyString(stringFromMap(body, "resolution"), stringFromMap(body, "size"))) * discount
|
||||||
|
return []any{billingLine(candidate, resource, unit, count, roundPrice(amount), discount, simulated)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func effectiveBillingConfig(candidate store.RuntimeModelCandidate) map[string]any {
|
func effectiveBillingConfig(candidate store.RuntimeModelCandidate) map[string]any {
|
||||||
@@ -121,6 +127,28 @@ func price(config map[string]any, key string) float64 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resourcePrice(config map[string]any, resource string, keys ...string) float64 {
|
||||||
|
for _, key := range keys {
|
||||||
|
if value := price(config, key); value > 0 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if resourceConfig, ok := config[resource].(map[string]any); ok {
|
||||||
|
for _, key := range keys {
|
||||||
|
if value := floatFromAny(resourceConfig[key]); value > 0 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if value := floatFromAny(resourceConfig["basePrice"]); value > 0 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if resource == "image_edit" {
|
||||||
|
return resourcePrice(config, "image", keys...)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
func weighted(config map[string]any, key string, name string) float64 {
|
func weighted(config map[string]any, key string, name string) float64 {
|
||||||
if strings.TrimSpace(name) == "" {
|
if strings.TrimSpace(name) == "" {
|
||||||
return 1
|
return 1
|
||||||
@@ -132,6 +160,39 @@ func weighted(config map[string]any, key string, name string) float64 {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resourceWeight(config map[string]any, resource string, key string, name string) float64 {
|
||||||
|
if value := weighted(config, key, name); value != 1 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(name) == "" {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
resourceConfig, _ := config[resource].(map[string]any)
|
||||||
|
if len(resourceConfig) == 0 && resource == "image_edit" {
|
||||||
|
resourceConfig, _ = config["image"].(map[string]any)
|
||||||
|
}
|
||||||
|
if weights, ok := resourceConfig["dynamicWeight"].(map[string]any); ok {
|
||||||
|
if value := floatFromAny(weights[name]); value > 0 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if weights, ok := resourceConfig[key].(map[string]any); ok {
|
||||||
|
if value := floatFromAny(weights[name]); value > 0 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmptyString(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if strings.TrimSpace(value) != "" {
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func roundPrice(value float64) float64 {
|
func roundPrice(value float64) float64 {
|
||||||
return math.Round(value*1000000) / 1000000
|
return math.Round(value*1000000) / 1000000
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||||
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
||||||
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type taskRecordDetails struct {
|
||||||
|
RequestID string
|
||||||
|
ResolvedModel string
|
||||||
|
Usage map[string]any
|
||||||
|
Metrics map[string]any
|
||||||
|
BillingSummary map[string]any
|
||||||
|
FinalChargeAmount float64
|
||||||
|
ResponseStartedAt time.Time
|
||||||
|
ResponseFinishedAt time.Time
|
||||||
|
ResponseDurationMS int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSuccessRecord(task store.GatewayTask, user *auth.User, body map[string]any, candidate store.RuntimeModelCandidate, response clients.Response, billings []any, simulated bool) taskRecordDetails {
|
||||||
|
usage := usageToMap(response.Usage)
|
||||||
|
metrics := taskMetrics(task, user, body, candidate, response, simulated)
|
||||||
|
summary := summarizeBillings(billings, simulated)
|
||||||
|
finalAmount := floatFromAny(summary["totalAmount"])
|
||||||
|
return taskRecordDetails{
|
||||||
|
RequestID: response.RequestID,
|
||||||
|
ResolvedModel: candidate.ModelName,
|
||||||
|
Usage: usage,
|
||||||
|
Metrics: metrics,
|
||||||
|
BillingSummary: summary,
|
||||||
|
FinalChargeAmount: finalAmount,
|
||||||
|
ResponseStartedAt: response.ResponseStartedAt,
|
||||||
|
ResponseFinishedAt: response.ResponseFinishedAt,
|
||||||
|
ResponseDurationMS: response.ResponseDurationMS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func taskMetrics(task store.GatewayTask, user *auth.User, body map[string]any, candidate store.RuntimeModelCandidate, response clients.Response, simulated bool) map[string]any {
|
||||||
|
metrics := map[string]any{
|
||||||
|
"kind": task.Kind,
|
||||||
|
"runMode": task.RunMode,
|
||||||
|
"requestedModel": task.Model,
|
||||||
|
"resolvedModel": candidate.ModelName,
|
||||||
|
"modelAlias": candidate.ModelAlias,
|
||||||
|
"providerModel": candidate.ProviderModelName,
|
||||||
|
"canonicalModel": candidate.CanonicalModelKey,
|
||||||
|
"modelType": candidate.ModelType,
|
||||||
|
"provider": candidate.Provider,
|
||||||
|
"platformId": candidate.PlatformID,
|
||||||
|
"platformName": candidate.PlatformName,
|
||||||
|
"platformModelId": candidate.PlatformModelID,
|
||||||
|
"clientId": candidate.ClientID,
|
||||||
|
"queueKey": candidate.QueueKey,
|
||||||
|
"requestId": response.RequestID,
|
||||||
|
"simulated": simulated,
|
||||||
|
}
|
||||||
|
if user != nil {
|
||||||
|
metrics["apiKeyId"] = user.APIKeyID
|
||||||
|
metrics["apiKeyName"] = user.APIKeyName
|
||||||
|
metrics["apiKeyPrefix"] = user.APIKeyPrefix
|
||||||
|
}
|
||||||
|
if response.ResponseDurationMS > 0 {
|
||||||
|
metrics["responseDurationMs"] = response.ResponseDurationMS
|
||||||
|
}
|
||||||
|
switch task.Kind {
|
||||||
|
case "chat.completions", "responses":
|
||||||
|
metrics["stream"] = boolFromMap(body, "stream")
|
||||||
|
metrics["messageCount"] = messageCount(body)
|
||||||
|
copyIfPresent(metrics, body, "temperature")
|
||||||
|
copyIfPresent(metrics, body, "max_tokens")
|
||||||
|
copyIfPresent(metrics, body, "max_output_tokens")
|
||||||
|
case "images.generations", "images.edits":
|
||||||
|
metrics["imageCount"] = requestedCount(body)
|
||||||
|
metrics["outputImageCount"] = outputDataCount(response.Result)
|
||||||
|
metrics["inputImageCount"] = imageInputCount(body)
|
||||||
|
metrics["hasMask"] = stringFromMap(body, "mask") != ""
|
||||||
|
copyIfPresent(metrics, body, "size")
|
||||||
|
copyIfPresent(metrics, body, "quality")
|
||||||
|
copyIfPresent(metrics, body, "style")
|
||||||
|
case "videos.generations":
|
||||||
|
metrics["hasReferenceImage"] = imageInputCount(body) > 0
|
||||||
|
metrics["hasReferenceVideo"] = hasAnyString(body, "video", "video_url", "videoUrl", "reference_video", "referenceVideo")
|
||||||
|
copyIfPresent(metrics, body, "duration")
|
||||||
|
copyIfPresent(metrics, body, "resolution")
|
||||||
|
copyIfPresent(metrics, body, "size")
|
||||||
|
copyIfPresent(metrics, body, "aspect_ratio")
|
||||||
|
copyIfPresent(metrics, body, "aspectRatio")
|
||||||
|
copyIfPresent(metrics, body, "fps")
|
||||||
|
}
|
||||||
|
return metrics
|
||||||
|
}
|
||||||
|
|
||||||
|
func usageToMap(usage clients.Usage) map[string]any {
|
||||||
|
out := map[string]any{}
|
||||||
|
if usage.InputTokens > 0 {
|
||||||
|
out["inputTokens"] = usage.InputTokens
|
||||||
|
out["promptTokens"] = usage.InputTokens
|
||||||
|
}
|
||||||
|
if usage.OutputTokens > 0 {
|
||||||
|
out["outputTokens"] = usage.OutputTokens
|
||||||
|
out["completionTokens"] = usage.OutputTokens
|
||||||
|
}
|
||||||
|
if usage.TotalTokens > 0 {
|
||||||
|
out["totalTokens"] = usage.TotalTokens
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func summarizeBillings(billings []any, simulated bool) map[string]any {
|
||||||
|
amountByCurrency := map[string]float64{}
|
||||||
|
for _, raw := range billings {
|
||||||
|
line, _ := raw.(map[string]any)
|
||||||
|
if line == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
currency := strings.TrimSpace(stringFromAny(line["currency"]))
|
||||||
|
if currency == "" {
|
||||||
|
currency = "resource"
|
||||||
|
}
|
||||||
|
amountByCurrency[currency] = roundPrice(amountByCurrency[currency] + floatFromAny(line["amount"]))
|
||||||
|
}
|
||||||
|
currency := ""
|
||||||
|
totalAmount := 0.0
|
||||||
|
for key, amount := range amountByCurrency {
|
||||||
|
if currency == "" {
|
||||||
|
currency = key
|
||||||
|
} else if currency != key {
|
||||||
|
currency = "mixed"
|
||||||
|
}
|
||||||
|
totalAmount += amount
|
||||||
|
}
|
||||||
|
if currency == "" {
|
||||||
|
currency = "resource"
|
||||||
|
}
|
||||||
|
totalAmount = roundPrice(totalAmount)
|
||||||
|
return map[string]any{
|
||||||
|
"lineCount": len(billings),
|
||||||
|
"totalAmount": totalAmount,
|
||||||
|
"amountByCurrency": amountByCurrency,
|
||||||
|
"currency": currency,
|
||||||
|
"simulated": simulated,
|
||||||
|
"finalCharge": map[string]any{
|
||||||
|
"amount": totalAmount,
|
||||||
|
"currency": currency,
|
||||||
|
"simulated": simulated,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func failureMetrics(err error, simulated bool) (string, map[string]any, time.Time, time.Time, int64) {
|
||||||
|
meta := clients.ErrorResponseMetadata(err)
|
||||||
|
metrics := map[string]any{
|
||||||
|
"simulated": simulated,
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
metrics["error"] = err.Error()
|
||||||
|
metrics["retryable"] = clients.IsRetryable(err)
|
||||||
|
}
|
||||||
|
if meta.StatusCode > 0 {
|
||||||
|
metrics["statusCode"] = meta.StatusCode
|
||||||
|
}
|
||||||
|
if meta.RequestID != "" {
|
||||||
|
metrics["requestId"] = meta.RequestID
|
||||||
|
}
|
||||||
|
if meta.ResponseDurationMS > 0 {
|
||||||
|
metrics["responseDurationMs"] = meta.ResponseDurationMS
|
||||||
|
}
|
||||||
|
return meta.RequestID, metrics, meta.ResponseStartedAt, meta.ResponseFinishedAt, meta.ResponseDurationMS
|
||||||
|
}
|
||||||
|
|
||||||
|
func messageCount(body map[string]any) int {
|
||||||
|
messages, _ := body["messages"].([]any)
|
||||||
|
return len(messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestedCount(body map[string]any) int {
|
||||||
|
count := int(floatFromAny(body["n"]))
|
||||||
|
if count <= 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func outputDataCount(result map[string]any) int {
|
||||||
|
data, _ := result["data"].([]any)
|
||||||
|
return len(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func imageInputCount(body map[string]any) int {
|
||||||
|
count := 0
|
||||||
|
for _, key := range []string{"image", "image_url", "imageUrl"} {
|
||||||
|
if stringFromMap(body, key) != "" {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, key := range []string{"image_urls", "imageUrls", "images"} {
|
||||||
|
if values, ok := body[key].([]any); ok {
|
||||||
|
count += len(values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasAnyString(body map[string]any, keys ...string) bool {
|
||||||
|
for _, key := range keys {
|
||||||
|
if stringFromMap(body, key) != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyIfPresent(target map[string]any, body map[string]any, key string) {
|
||||||
|
if value, ok := body[key]; ok && value != nil {
|
||||||
|
target[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,18 +41,26 @@ func New(cfg config.Config, db *store.Store, logger *slog.Logger) *Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Execute(ctx context.Context, task store.GatewayTask, user *auth.User) (Result, error) {
|
func (s *Service) Execute(ctx context.Context, task store.GatewayTask, user *auth.User) (Result, error) {
|
||||||
|
return s.execute(ctx, task, user, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ExecuteStream(ctx context.Context, task store.GatewayTask, user *auth.User, onDelta clients.StreamDelta) (Result, error) {
|
||||||
|
return s.execute(ctx, task, user, onDelta)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) execute(ctx context.Context, task store.GatewayTask, user *auth.User, onDelta clients.StreamDelta) (Result, error) {
|
||||||
modelType := modelTypeFromKind(task.Kind)
|
modelType := modelTypeFromKind(task.Kind)
|
||||||
body := normalizeRequest(task.Kind, task.Request)
|
body := normalizeRequest(task.Kind, task.Request)
|
||||||
if err := validateRequest(task.Kind, body); err != nil {
|
if err := validateRequest(task.Kind, body); err != nil {
|
||||||
failed, finishErr := s.failTask(ctx, task.ID, "bad_request", err.Error(), task.RunMode == "simulation")
|
failed, finishErr := s.failTask(ctx, task.ID, "bad_request", err.Error(), task.RunMode == "simulation", err)
|
||||||
if finishErr != nil {
|
if finishErr != nil {
|
||||||
return Result{}, finishErr
|
return Result{}, finishErr
|
||||||
}
|
}
|
||||||
return Result{Task: failed, Output: failed.Result}, err
|
return Result{Task: failed, Output: failed.Result}, err
|
||||||
}
|
}
|
||||||
candidates, err := s.store.ListModelCandidates(ctx, task.Model, modelType)
|
candidates, err := s.store.ListModelCandidates(ctx, task.Model, modelType, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
failed, finishErr := s.failTask(ctx, task.ID, "no_model_candidate", err.Error(), task.RunMode == "simulation")
|
failed, finishErr := s.failTask(ctx, task.ID, "no_model_candidate", err.Error(), task.RunMode == "simulation", err)
|
||||||
if finishErr != nil {
|
if finishErr != nil {
|
||||||
return Result{}, finishErr
|
return Result{}, finishErr
|
||||||
}
|
}
|
||||||
@@ -72,14 +80,35 @@ func (s *Service) Execute(ctx context.Context, task store.GatewayTask, user *aut
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
attemptNo := index + 1
|
attemptNo := index + 1
|
||||||
response, err := s.runCandidate(ctx, task, user, body, candidate, attemptNo)
|
response, err := s.runCandidate(ctx, task, user, body, candidate, attemptNo, onDelta)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
billings := s.billings(ctx, user, task.Kind, body, candidate, response, isSimulation(task, candidate))
|
billings := s.billings(ctx, user, task.Kind, body, candidate, response, isSimulation(task, candidate))
|
||||||
finished, finishErr := s.store.FinishTaskSuccess(ctx, task.ID, response.Result, billings)
|
record := buildSuccessRecord(task, user, body, candidate, response, billings, isSimulation(task, candidate))
|
||||||
|
finished, finishErr := s.store.FinishTaskSuccess(ctx, store.FinishTaskSuccessInput{
|
||||||
|
TaskID: task.ID,
|
||||||
|
Result: response.Result,
|
||||||
|
Billings: billings,
|
||||||
|
RequestID: record.RequestID,
|
||||||
|
ResolvedModel: record.ResolvedModel,
|
||||||
|
Usage: record.Usage,
|
||||||
|
Metrics: record.Metrics,
|
||||||
|
BillingSummary: record.BillingSummary,
|
||||||
|
FinalChargeAmount: record.FinalChargeAmount,
|
||||||
|
ResponseStartedAt: record.ResponseStartedAt,
|
||||||
|
ResponseFinishedAt: record.ResponseFinishedAt,
|
||||||
|
ResponseDurationMS: record.ResponseDurationMS,
|
||||||
|
})
|
||||||
if finishErr != nil {
|
if finishErr != nil {
|
||||||
return Result{}, finishErr
|
return Result{}, finishErr
|
||||||
}
|
}
|
||||||
if err := s.emit(ctx, task.ID, "task.completed", "succeeded", "completed", 1, "task completed", map[string]any{"result": response.Result, "billings": billings}, isSimulation(task, candidate)); err != nil {
|
if err := s.emit(ctx, task.ID, "task.completed", "succeeded", "completed", 1, "task completed", map[string]any{
|
||||||
|
"result": response.Result,
|
||||||
|
"billings": billings,
|
||||||
|
"usage": record.Usage,
|
||||||
|
"metrics": record.Metrics,
|
||||||
|
"billingSummary": record.BillingSummary,
|
||||||
|
"requestId": record.RequestID,
|
||||||
|
}, isSimulation(task, candidate)); err != nil {
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
return Result{Task: finished, Output: response.Result}, nil
|
return Result{Task: finished, Output: response.Result}, nil
|
||||||
@@ -98,14 +127,14 @@ func (s *Service) Execute(ctx context.Context, task store.GatewayTask, user *aut
|
|||||||
if lastErr != nil {
|
if lastErr != nil {
|
||||||
message = lastErr.Error()
|
message = lastErr.Error()
|
||||||
}
|
}
|
||||||
failed, err := s.failTask(ctx, task.ID, code, message, task.RunMode == "simulation")
|
failed, err := s.failTask(ctx, task.ID, code, message, task.RunMode == "simulation", lastErr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
return Result{Task: failed, Output: failed.Result}, lastErr
|
return Result{Task: failed, Output: failed.Result}, lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user *auth.User, body map[string]any, candidate store.RuntimeModelCandidate, attemptNo int) (clients.Response, error) {
|
func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user *auth.User, body map[string]any, candidate store.RuntimeModelCandidate, attemptNo int, onDelta clients.StreamDelta) (clients.Response, error) {
|
||||||
simulated := isSimulation(task, candidate)
|
simulated := isSimulation(task, candidate)
|
||||||
if err := s.emit(ctx, task.ID, "task.attempt.started", "running", "submitting", 0.25, "client attempt started", map[string]any{"attempt": attemptNo, "clientId": candidate.ClientID}, simulated); err != nil {
|
if err := s.emit(ctx, task.ID, "task.attempt.started", "running", "submitting", 0.25, "client attempt started", map[string]any{"attempt": attemptNo, "clientId": candidate.ClientID}, simulated); err != nil {
|
||||||
return clients.Response{}, err
|
return clients.Response{}, err
|
||||||
@@ -127,7 +156,14 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
|
|||||||
reservations := s.rateLimitReservations(ctx, user, candidate, body)
|
reservations := s.rateLimitReservations(ctx, user, candidate, body)
|
||||||
limitResult, err := s.store.ReserveRateLimits(ctx, task.ID, reservations)
|
limitResult, err := s.store.ReserveRateLimits(ctx, task.ID, reservations)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = s.store.FinishTaskAttempt(ctx, store.FinishTaskAttemptInput{AttemptID: attemptID, Status: "failed", Retryable: false, ErrorCode: "rate_limit", ErrorMessage: err.Error()})
|
_ = s.store.FinishTaskAttempt(ctx, store.FinishTaskAttemptInput{
|
||||||
|
AttemptID: attemptID,
|
||||||
|
Status: "failed",
|
||||||
|
Retryable: false,
|
||||||
|
Metrics: map[string]any{"error": err.Error(), "candidateModel": candidate.ModelName, "clientId": candidate.ClientID},
|
||||||
|
ErrorCode: "rate_limit",
|
||||||
|
ErrorMessage: err.Error(),
|
||||||
|
})
|
||||||
return clients.Response{}, &clients.ClientError{Code: "rate_limit", Message: err.Error(), Retryable: false}
|
return clients.Response{}, &clients.ClientError{Code: "rate_limit", Message: err.Error(), Retryable: false}
|
||||||
}
|
}
|
||||||
defer s.store.ReleaseConcurrencyLeases(context.WithoutCancel(ctx), limitResult.LeaseIDs)
|
defer s.store.ReleaseConcurrencyLeases(context.WithoutCancel(ctx), limitResult.LeaseIDs)
|
||||||
@@ -138,34 +174,77 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
|
|||||||
defer s.store.RecordClientRelease(context.WithoutCancel(ctx), candidate.ClientID, "")
|
defer s.store.RecordClientRelease(context.WithoutCancel(ctx), candidate.ClientID, "")
|
||||||
|
|
||||||
client := s.clientFor(candidate, simulated)
|
client := s.clientFor(candidate, simulated)
|
||||||
|
callStartedAt := time.Now()
|
||||||
response, err := client.Run(ctx, clients.Request{
|
response, err := client.Run(ctx, clients.Request{
|
||||||
Kind: task.Kind,
|
Kind: task.Kind,
|
||||||
ModelType: candidate.ModelType,
|
ModelType: candidate.ModelType,
|
||||||
Model: task.Model,
|
Model: task.Model,
|
||||||
Body: body,
|
Body: body,
|
||||||
Candidate: candidate,
|
Candidate: candidate,
|
||||||
Stream: boolFromMap(body, "stream"),
|
Stream: boolFromMap(body, "stream"),
|
||||||
|
StreamDelta: onDelta,
|
||||||
})
|
})
|
||||||
|
callFinishedAt := time.Now()
|
||||||
|
if response.ResponseStartedAt.IsZero() {
|
||||||
|
response.ResponseStartedAt = callStartedAt
|
||||||
|
}
|
||||||
|
if response.ResponseFinishedAt.IsZero() {
|
||||||
|
response.ResponseFinishedAt = callFinishedAt
|
||||||
|
}
|
||||||
|
if response.ResponseDurationMS == 0 {
|
||||||
|
response.ResponseDurationMS = response.ResponseFinishedAt.Sub(response.ResponseStartedAt).Milliseconds()
|
||||||
|
if response.ResponseDurationMS < 0 {
|
||||||
|
response.ResponseDurationMS = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
retryable := clients.IsRetryable(err)
|
retryable := clients.IsRetryable(err)
|
||||||
|
requestID, metrics, responseStartedAt, responseFinishedAt, responseDurationMS := failureMetrics(err, simulated)
|
||||||
|
if responseStartedAt.IsZero() {
|
||||||
|
responseStartedAt = callStartedAt
|
||||||
|
}
|
||||||
|
if responseFinishedAt.IsZero() {
|
||||||
|
responseFinishedAt = callFinishedAt
|
||||||
|
}
|
||||||
|
if responseDurationMS == 0 {
|
||||||
|
responseDurationMS = responseFinishedAt.Sub(responseStartedAt).Milliseconds()
|
||||||
|
if responseDurationMS < 0 {
|
||||||
|
responseDurationMS = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
_ = s.store.FinishTaskAttempt(ctx, store.FinishTaskAttemptInput{
|
_ = s.store.FinishTaskAttempt(ctx, store.FinishTaskAttemptInput{
|
||||||
AttemptID: attemptID,
|
AttemptID: attemptID,
|
||||||
Status: "failed",
|
Status: "failed",
|
||||||
Retryable: retryable,
|
Retryable: retryable,
|
||||||
ErrorCode: clients.ErrorCode(err),
|
RequestID: requestID,
|
||||||
ErrorMessage: err.Error(),
|
Metrics: metrics,
|
||||||
|
ResponseStartedAt: responseStartedAt,
|
||||||
|
ResponseFinishedAt: responseFinishedAt,
|
||||||
|
ResponseDurationMS: responseDurationMS,
|
||||||
|
ErrorCode: clients.ErrorCode(err),
|
||||||
|
ErrorMessage: err.Error(),
|
||||||
})
|
})
|
||||||
_ = s.emit(ctx, task.ID, "task.attempt.failed", "running", "attempt_failed", 0.45, err.Error(), map[string]any{"attempt": attemptNo, "retryable": retryable}, simulated)
|
_ = s.emit(ctx, task.ID, "task.attempt.failed", "running", "attempt_failed", 0.45, err.Error(), map[string]any{"attempt": attemptNo, "retryable": retryable, "requestId": requestID, "metrics": metrics}, simulated)
|
||||||
return clients.Response{}, err
|
return clients.Response{}, err
|
||||||
}
|
}
|
||||||
uploadedResult, err := s.uploadGeneratedAssets(ctx, response.Result)
|
uploadedResult, err := s.uploadGeneratedAssets(ctx, response.Result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
metrics := taskMetrics(task, user, body, candidate, response, simulated)
|
||||||
|
metrics["error"] = err.Error()
|
||||||
|
metrics["retryable"] = clients.IsRetryable(err)
|
||||||
_ = s.store.FinishTaskAttempt(ctx, store.FinishTaskAttemptInput{
|
_ = s.store.FinishTaskAttempt(ctx, store.FinishTaskAttemptInput{
|
||||||
AttemptID: attemptID,
|
AttemptID: attemptID,
|
||||||
Status: "failed",
|
Status: "failed",
|
||||||
Retryable: clients.IsRetryable(err),
|
Retryable: clients.IsRetryable(err),
|
||||||
ErrorCode: clients.ErrorCode(err),
|
RequestID: response.RequestID,
|
||||||
ErrorMessage: err.Error(),
|
Usage: usageToMap(response.Usage),
|
||||||
|
Metrics: metrics,
|
||||||
|
ResponseSnapshot: response.Result,
|
||||||
|
ResponseStartedAt: response.ResponseStartedAt,
|
||||||
|
ResponseFinishedAt: response.ResponseFinishedAt,
|
||||||
|
ResponseDurationMS: response.ResponseDurationMS,
|
||||||
|
ErrorCode: clients.ErrorCode(err),
|
||||||
|
ErrorMessage: err.Error(),
|
||||||
})
|
})
|
||||||
return clients.Response{}, err
|
return clients.Response{}, err
|
||||||
}
|
}
|
||||||
@@ -176,9 +255,15 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := s.store.FinishTaskAttempt(ctx, store.FinishTaskAttemptInput{
|
if err := s.store.FinishTaskAttempt(ctx, store.FinishTaskAttemptInput{
|
||||||
AttemptID: attemptID,
|
AttemptID: attemptID,
|
||||||
Status: "succeeded",
|
Status: "succeeded",
|
||||||
ResponseSnapshot: response.Result,
|
RequestID: response.RequestID,
|
||||||
|
Usage: usageToMap(response.Usage),
|
||||||
|
Metrics: taskMetrics(task, user, body, candidate, response, simulated),
|
||||||
|
ResponseSnapshot: response.Result,
|
||||||
|
ResponseStartedAt: response.ResponseStartedAt,
|
||||||
|
ResponseFinishedAt: response.ResponseFinishedAt,
|
||||||
|
ResponseDurationMS: response.ResponseDurationMS,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return clients.Response{}, err
|
return clients.Response{}, err
|
||||||
}
|
}
|
||||||
@@ -189,19 +274,32 @@ func (s *Service) clientFor(candidate store.RuntimeModelCandidate, simulated boo
|
|||||||
if simulated {
|
if simulated {
|
||||||
return s.clients["simulation"]
|
return s.clients["simulation"]
|
||||||
}
|
}
|
||||||
key := strings.ToLower(candidate.Provider)
|
key := strings.ToLower(strings.TrimSpace(candidate.SpecType))
|
||||||
|
if key == "" {
|
||||||
|
key = strings.ToLower(strings.TrimSpace(candidate.Provider))
|
||||||
|
}
|
||||||
if client, ok := s.clients[key]; ok {
|
if client, ok := s.clients[key]; ok {
|
||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
return s.clients["openai"]
|
return s.clients["openai"]
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) failTask(ctx context.Context, taskID string, code string, message string, simulated bool) (store.GatewayTask, error) {
|
func (s *Service) failTask(ctx context.Context, taskID string, code string, message string, simulated bool, cause error) (store.GatewayTask, error) {
|
||||||
failed, err := s.store.FinishTaskFailure(ctx, taskID, code, message)
|
requestID, metrics, responseStartedAt, responseFinishedAt, responseDurationMS := failureMetrics(cause, simulated)
|
||||||
|
failed, err := s.store.FinishTaskFailure(ctx, store.FinishTaskFailureInput{
|
||||||
|
TaskID: taskID,
|
||||||
|
Code: code,
|
||||||
|
Message: message,
|
||||||
|
RequestID: requestID,
|
||||||
|
Metrics: metrics,
|
||||||
|
ResponseStartedAt: responseStartedAt,
|
||||||
|
ResponseFinishedAt: responseFinishedAt,
|
||||||
|
ResponseDurationMS: responseDurationMS,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return store.GatewayTask{}, err
|
return store.GatewayTask{}, err
|
||||||
}
|
}
|
||||||
if eventErr := s.emit(ctx, taskID, "task.failed", "failed", "failed", 1, message, map[string]any{"code": code}, simulated); eventErr != nil {
|
if eventErr := s.emit(ctx, taskID, "task.failed", "failed", "failed", 1, message, map[string]any{"code": code, "requestId": requestID, "metrics": metrics}, simulated); eventErr != nil {
|
||||||
return store.GatewayTask{}, eventErr
|
return store.GatewayTask{}, eventErr
|
||||||
}
|
}
|
||||||
return failed, nil
|
return failed, nil
|
||||||
@@ -221,14 +319,23 @@ func (s *Service) emit(ctx context.Context, taskID string, eventType string, sta
|
|||||||
func modelTypeFromKind(kind string) string {
|
func modelTypeFromKind(kind string) string {
|
||||||
switch kind {
|
switch kind {
|
||||||
case "chat.completions", "responses":
|
case "chat.completions", "responses":
|
||||||
return "chat"
|
return "text_generate"
|
||||||
case "images.generations", "images.edits":
|
case "images.generations", "images.edits":
|
||||||
return "image"
|
if kind == "images.edits" {
|
||||||
|
return "image_edit"
|
||||||
|
}
|
||||||
|
return "image_generate"
|
||||||
|
case "videos.generations":
|
||||||
|
return "video_generate"
|
||||||
default:
|
default:
|
||||||
return "task"
|
return "task"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isTextGenerationKind(kind string) bool {
|
||||||
|
return kind == "chat.completions" || kind == "responses"
|
||||||
|
}
|
||||||
|
|
||||||
func isSimulation(task store.GatewayTask, candidate store.RuntimeModelCandidate) bool {
|
func isSimulation(task store.GatewayTask, candidate store.RuntimeModelCandidate) bool {
|
||||||
if task.RunMode == "simulation" {
|
if task.RunMode == "simulation" {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ var (
|
|||||||
ErrInvalidCredentials = errors.New("invalid account or password")
|
ErrInvalidCredentials = errors.New("invalid account or password")
|
||||||
ErrInvalidInvitation = errors.New("invalid or expired invitation code")
|
ErrInvalidInvitation = errors.New("invalid or expired invitation code")
|
||||||
ErrLocalUserRequired = errors.New("local gateway user is required")
|
ErrLocalUserRequired = errors.New("local gateway user is required")
|
||||||
|
ErrProtectedDefault = errors.New("protected default resource cannot be deleted")
|
||||||
ErrUserAlreadyExists = errors.New("user already exists")
|
ErrUserAlreadyExists = errors.New("user already exists")
|
||||||
ErrWeakPassword = errors.New("password must be at least 8 characters")
|
ErrWeakPassword = errors.New("password must be at least 8 characters")
|
||||||
)
|
)
|
||||||
@@ -54,6 +55,7 @@ type Platform struct {
|
|||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
PlatformKey string `json:"platformKey"`
|
PlatformKey string `json:"platformKey"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
InternalName string `json:"internalName,omitempty"`
|
||||||
BaseURL string `json:"baseUrl,omitempty"`
|
BaseURL string `json:"baseUrl,omitempty"`
|
||||||
AuthType string `json:"authType"`
|
AuthType string `json:"authType"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
@@ -62,6 +64,9 @@ type Platform struct {
|
|||||||
DefaultDiscountFactor float64 `json:"defaultDiscountFactor"`
|
DefaultDiscountFactor float64 `json:"defaultDiscountFactor"`
|
||||||
PricingRuleSetID string `json:"pricingRuleSetId,omitempty"`
|
PricingRuleSetID string `json:"pricingRuleSetId,omitempty"`
|
||||||
Config map[string]any `json:"config,omitempty"`
|
Config map[string]any `json:"config,omitempty"`
|
||||||
|
CredentialsPreview map[string]any `json:"credentialsPreview,omitempty"`
|
||||||
|
RetryPolicy map[string]any `json:"retryPolicy,omitempty"`
|
||||||
|
RateLimitPolicy map[string]any `json:"rateLimitPolicy,omitempty"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
@@ -70,10 +75,13 @@ type CreatePlatformInput struct {
|
|||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
PlatformKey string `json:"platformKey"`
|
PlatformKey string `json:"platformKey"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
InternalName string `json:"internalName"`
|
||||||
BaseURL string `json:"baseUrl"`
|
BaseURL string `json:"baseUrl"`
|
||||||
AuthType string `json:"authType"`
|
AuthType string `json:"authType"`
|
||||||
Credentials map[string]any `json:"credentials"`
|
Credentials map[string]any `json:"credentials"`
|
||||||
Config map[string]any `json:"config"`
|
Config map[string]any `json:"config"`
|
||||||
|
RetryPolicy map[string]any `json:"retryPolicy"`
|
||||||
|
RateLimitPolicy map[string]any `json:"rateLimitPolicy"`
|
||||||
DefaultPricingMode string `json:"defaultPricingMode"`
|
DefaultPricingMode string `json:"defaultPricingMode"`
|
||||||
DefaultDiscountFactor float64 `json:"defaultDiscountFactor"`
|
DefaultDiscountFactor float64 `json:"defaultDiscountFactor"`
|
||||||
PricingRuleSetID string `json:"pricingRuleSetId"`
|
PricingRuleSetID string `json:"pricingRuleSetId"`
|
||||||
@@ -106,6 +114,11 @@ type APIKey struct {
|
|||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PlayableAPIKey struct {
|
||||||
|
APIKey
|
||||||
|
Secret string `json:"secret"`
|
||||||
|
}
|
||||||
|
|
||||||
type CreatedAPIKey struct {
|
type CreatedAPIKey struct {
|
||||||
APIKey APIKey `json:"apiKey"`
|
APIKey APIKey `json:"apiKey"`
|
||||||
Secret string `json:"secret"`
|
Secret string `json:"secret"`
|
||||||
@@ -128,11 +141,32 @@ type PlatformModel struct {
|
|||||||
PricingRuleSetID string `json:"pricingRuleSetId,omitempty"`
|
PricingRuleSetID string `json:"pricingRuleSetId,omitempty"`
|
||||||
BillingConfigOverride map[string]any `json:"billingConfigOverride,omitempty"`
|
BillingConfigOverride map[string]any `json:"billingConfigOverride,omitempty"`
|
||||||
BillingConfig map[string]any `json:"billingConfig,omitempty"`
|
BillingConfig map[string]any `json:"billingConfig,omitempty"`
|
||||||
|
PermissionConfig map[string]any `json:"permissionConfig,omitempty"`
|
||||||
|
RetryPolicy map[string]any `json:"retryPolicy,omitempty"`
|
||||||
|
RateLimitPolicy map[string]any `json:"rateLimitPolicy,omitempty"`
|
||||||
|
RuntimePolicySetID string `json:"runtimePolicySetId,omitempty"`
|
||||||
|
RuntimePolicyOverride map[string]any `json:"runtimePolicyOverride,omitempty"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AccessRule struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
SubjectType string `json:"subjectType"`
|
||||||
|
SubjectID string `json:"subjectId"`
|
||||||
|
ResourceType string `json:"resourceType"`
|
||||||
|
ResourceID string `json:"resourceId"`
|
||||||
|
Effect string `json:"effect"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
MinPermissionLevel int `json:"minPermissionLevel"`
|
||||||
|
Conditions map[string]any `json:"conditions,omitempty"`
|
||||||
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
type CatalogProvider struct {
|
type CatalogProvider struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ProviderKey string `json:"providerKey"`
|
ProviderKey string `json:"providerKey"`
|
||||||
@@ -140,6 +174,8 @@ type CatalogProvider struct {
|
|||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
ProviderType string `json:"providerType"`
|
ProviderType string `json:"providerType"`
|
||||||
IconPath string `json:"iconPath,omitempty"`
|
IconPath string `json:"iconPath,omitempty"`
|
||||||
|
DefaultBaseURL string `json:"defaultBaseUrl,omitempty"`
|
||||||
|
DefaultAuthType string `json:"defaultAuthType,omitempty"`
|
||||||
Source string `json:"source,omitempty"`
|
Source string `json:"source,omitempty"`
|
||||||
CapabilitySchema map[string]any `json:"capabilitySchema,omitempty"`
|
CapabilitySchema map[string]any `json:"capabilitySchema,omitempty"`
|
||||||
DefaultRateLimitPolicy map[string]any `json:"defaultRateLimitPolicy,omitempty"`
|
DefaultRateLimitPolicy map[string]any `json:"defaultRateLimitPolicy,omitempty"`
|
||||||
@@ -154,18 +190,40 @@ type BaseModel struct {
|
|||||||
ProviderKey string `json:"providerKey"`
|
ProviderKey string `json:"providerKey"`
|
||||||
CanonicalModelKey string `json:"canonicalModelKey"`
|
CanonicalModelKey string `json:"canonicalModelKey"`
|
||||||
ProviderModelName string `json:"providerModelName"`
|
ProviderModelName string `json:"providerModelName"`
|
||||||
ModelType string `json:"modelType"`
|
ModelType StringList `json:"modelType"`
|
||||||
DisplayName string `json:"displayName"`
|
ModelAlias string `json:"modelAlias"`
|
||||||
|
DisplayName string `json:"-"`
|
||||||
Capabilities map[string]any `json:"capabilities,omitempty"`
|
Capabilities map[string]any `json:"capabilities,omitempty"`
|
||||||
BaseBillingConfig map[string]any `json:"baseBillingConfig,omitempty"`
|
BaseBillingConfig map[string]any `json:"baseBillingConfig,omitempty"`
|
||||||
DefaultRateLimitPolicy map[string]any `json:"defaultRateLimitPolicy,omitempty"`
|
DefaultRateLimitPolicy map[string]any `json:"defaultRateLimitPolicy,omitempty"`
|
||||||
|
PricingRuleSetID string `json:"pricingRuleSetId,omitempty"`
|
||||||
|
RuntimePolicySetID string `json:"runtimePolicySetId,omitempty"`
|
||||||
|
RuntimePolicyOverride map[string]any `json:"runtimePolicyOverride,omitempty"`
|
||||||
Metadata map[string]any `json:"metadata,omitempty"`
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
|
CatalogType string `json:"catalogType"`
|
||||||
|
DefaultSnapshot map[string]any `json:"defaultSnapshot,omitempty"`
|
||||||
|
CustomizedAt string `json:"customizedAt,omitempty"`
|
||||||
PricingVersion int `json:"pricingVersion"`
|
PricingVersion int `json:"pricingVersion"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RuntimePolicySet struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
PolicyKey string `json:"policyKey"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
RateLimitPolicy map[string]any `json:"rateLimitPolicy,omitempty"`
|
||||||
|
RetryPolicy map[string]any `json:"retryPolicy,omitempty"`
|
||||||
|
AutoDisablePolicy map[string]any `json:"autoDisablePolicy,omitempty"`
|
||||||
|
DegradePolicy map[string]any `json:"degradePolicy,omitempty"`
|
||||||
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
type PricingRule struct {
|
type PricingRule struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
RuleSetID string `json:"ruleSetId,omitempty"`
|
RuleSetID string `json:"ruleSetId,omitempty"`
|
||||||
@@ -298,27 +356,54 @@ type CreateTaskInput struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GatewayTask struct {
|
type GatewayTask struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
RunMode string `json:"runMode"`
|
RunMode string `json:"runMode"`
|
||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
GatewayUserID string `json:"gatewayUserId,omitempty"`
|
GatewayUserID string `json:"gatewayUserId,omitempty"`
|
||||||
UserSource string `json:"userSource,omitempty"`
|
UserSource string `json:"userSource,omitempty"`
|
||||||
GatewayTenantID string `json:"gatewayTenantId,omitempty"`
|
GatewayTenantID string `json:"gatewayTenantId,omitempty"`
|
||||||
TenantID string `json:"tenantId,omitempty"`
|
TenantID string `json:"tenantId,omitempty"`
|
||||||
TenantKey string `json:"tenantKey,omitempty"`
|
TenantKey string `json:"tenantKey,omitempty"`
|
||||||
UserGroupID string `json:"userGroupId,omitempty"`
|
APIKeyID string `json:"apiKeyId,omitempty"`
|
||||||
UserGroupKey string `json:"userGroupKey,omitempty"`
|
APIKeyName string `json:"apiKeyName,omitempty"`
|
||||||
Model string `json:"model"`
|
APIKeyPrefix string `json:"apiKeyPrefix,omitempty"`
|
||||||
Request map[string]any `json:"request,omitempty"`
|
UserGroupID string `json:"userGroupId,omitempty"`
|
||||||
Status string `json:"status"`
|
UserGroupKey string `json:"userGroupKey,omitempty"`
|
||||||
Result map[string]any `json:"result,omitempty"`
|
Model string `json:"model"`
|
||||||
Billings []any `json:"billings,omitempty"`
|
ModelType string `json:"modelType,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
RequestedModel string `json:"requestedModel,omitempty"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
ResolvedModel string `json:"resolvedModel,omitempty"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
RequestID string `json:"requestId,omitempty"`
|
||||||
|
Request map[string]any `json:"request,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Result map[string]any `json:"result,omitempty"`
|
||||||
|
Billings []any `json:"billings,omitempty"`
|
||||||
|
Usage map[string]any `json:"usage"`
|
||||||
|
Metrics map[string]any `json:"metrics"`
|
||||||
|
BillingSummary map[string]any `json:"billingSummary"`
|
||||||
|
FinalChargeAmount float64 `json:"finalChargeAmount"`
|
||||||
|
ResponseStartedAt string `json:"responseStartedAt,omitempty"`
|
||||||
|
ResponseFinishedAt string `json:"responseFinishedAt,omitempty"`
|
||||||
|
ResponseDurationMS int64 `json:"responseDurationMs"`
|
||||||
|
FinishedAt string `json:"finishedAt,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const gatewayTaskColumns = `
|
||||||
|
id::text, kind, run_mode, user_id, COALESCE(gateway_user_id::text, ''), user_source,
|
||||||
|
COALESCE(gateway_tenant_id::text, ''), COALESCE(tenant_id, ''), COALESCE(tenant_key, ''),
|
||||||
|
COALESCE(api_key_id, ''), COALESCE(api_key_name, ''), COALESCE(api_key_prefix, ''),
|
||||||
|
COALESCE(user_group_id::text, ''), COALESCE(user_group_key, ''), model,
|
||||||
|
COALESCE(model_type, ''), COALESCE(requested_model, ''), COALESCE(resolved_model, ''), COALESCE(request_id, ''),
|
||||||
|
request, status, COALESCE(result, '{}'::jsonb), COALESCE(billings, '[]'::jsonb),
|
||||||
|
COALESCE(usage, '{}'::jsonb), COALESCE(metrics, '{}'::jsonb), COALESCE(billing_summary, '{}'::jsonb),
|
||||||
|
COALESCE(final_charge_amount, 0)::float8, COALESCE(response_started_at::text, ''),
|
||||||
|
COALESCE(response_finished_at::text, ''), COALESCE(response_duration_ms, 0), COALESCE(error, ''),
|
||||||
|
created_at, updated_at, COALESCE(finished_at::text, '')`
|
||||||
|
|
||||||
type TaskEvent struct {
|
type TaskEvent struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
TaskID string `json:"taskId"`
|
TaskID string `json:"taskId"`
|
||||||
@@ -335,9 +420,9 @@ type TaskEvent struct {
|
|||||||
|
|
||||||
func (s *Store) ListPlatforms(ctx context.Context) ([]Platform, error) {
|
func (s *Store) ListPlatforms(ctx context.Context) ([]Platform, error) {
|
||||||
rows, err := s.pool.Query(ctx, `
|
rows, err := s.pool.Query(ctx, `
|
||||||
SELECT id::text, provider, platform_key, name, COALESCE(base_url, ''), auth_type, status, priority,
|
SELECT id::text, provider, platform_key, name, COALESCE(internal_name, ''), COALESCE(base_url, ''), auth_type, status, priority,
|
||||||
default_pricing_mode, default_discount_factor::float8, COALESCE(pricing_rule_set_id::text, ''),
|
default_pricing_mode, default_discount_factor::float8, COALESCE(pricing_rule_set_id::text, ''),
|
||||||
config, created_at, updated_at
|
config, credentials, retry_policy, rate_limit_policy, created_at, updated_at
|
||||||
FROM integration_platforms
|
FROM integration_platforms
|
||||||
ORDER BY priority ASC, created_at DESC`)
|
ORDER BY priority ASC, created_at DESC`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -349,11 +434,15 @@ ORDER BY priority ASC, created_at DESC`)
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var platform Platform
|
var platform Platform
|
||||||
var configBytes []byte
|
var configBytes []byte
|
||||||
|
var credentialsBytes []byte
|
||||||
|
var retryPolicyBytes []byte
|
||||||
|
var rateLimitPolicyBytes []byte
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&platform.ID,
|
&platform.ID,
|
||||||
&platform.Provider,
|
&platform.Provider,
|
||||||
&platform.PlatformKey,
|
&platform.PlatformKey,
|
||||||
&platform.Name,
|
&platform.Name,
|
||||||
|
&platform.InternalName,
|
||||||
&platform.BaseURL,
|
&platform.BaseURL,
|
||||||
&platform.AuthType,
|
&platform.AuthType,
|
||||||
&platform.Status,
|
&platform.Status,
|
||||||
@@ -362,20 +451,28 @@ ORDER BY priority ASC, created_at DESC`)
|
|||||||
&platform.DefaultDiscountFactor,
|
&platform.DefaultDiscountFactor,
|
||||||
&platform.PricingRuleSetID,
|
&platform.PricingRuleSetID,
|
||||||
&configBytes,
|
&configBytes,
|
||||||
|
&credentialsBytes,
|
||||||
|
&retryPolicyBytes,
|
||||||
|
&rateLimitPolicyBytes,
|
||||||
&platform.CreatedAt,
|
&platform.CreatedAt,
|
||||||
&platform.UpdatedAt,
|
&platform.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
platform.Config = decodeObject(configBytes)
|
platform.Config = decodeObject(configBytes)
|
||||||
|
platform.CredentialsPreview = maskCredentialsPreview(credentialsBytes)
|
||||||
|
platform.RetryPolicy = decodeObject(retryPolicyBytes)
|
||||||
|
platform.RateLimitPolicy = decodeObject(rateLimitPolicyBytes)
|
||||||
platforms = append(platforms, platform)
|
platforms = append(platforms, platform)
|
||||||
}
|
}
|
||||||
return platforms, rows.Err()
|
return platforms, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) CreatePlatform(ctx context.Context, input CreatePlatformInput) (Platform, error) {
|
func (s *Store) CreatePlatform(ctx context.Context, input CreatePlatformInput) (Platform, error) {
|
||||||
credentials, _ := json.Marshal(input.Credentials)
|
credentials, _ := json.Marshal(emptyObjectIfNil(input.Credentials))
|
||||||
config, _ := json.Marshal(input.Config)
|
config, _ := json.Marshal(emptyObjectIfNil(input.Config))
|
||||||
|
retryPolicy, _ := json.Marshal(emptyObjectIfNil(input.RetryPolicy))
|
||||||
|
rateLimitPolicy, _ := json.Marshal(emptyObjectIfNil(input.RateLimitPolicy))
|
||||||
if input.DefaultPricingMode == "" {
|
if input.DefaultPricingMode == "" {
|
||||||
input.DefaultPricingMode = "inherit_discount"
|
input.DefaultPricingMode = "inherit_discount"
|
||||||
}
|
}
|
||||||
@@ -387,17 +484,31 @@ func (s *Store) CreatePlatform(ctx context.Context, input CreatePlatformInput) (
|
|||||||
}
|
}
|
||||||
var platform Platform
|
var platform Platform
|
||||||
var configBytes []byte
|
var configBytes []byte
|
||||||
|
var credentialsResultBytes []byte
|
||||||
|
var retryPolicyBytes []byte
|
||||||
|
var rateLimitPolicyBytes []byte
|
||||||
err := s.pool.QueryRow(ctx, `
|
err := s.pool.QueryRow(ctx, `
|
||||||
INSERT INTO integration_platforms (provider, platform_key, name, base_url, auth_type, credentials, config, default_pricing_mode, default_discount_factor, pricing_rule_set_id, priority)
|
INSERT INTO integration_platforms (
|
||||||
VALUES ($1, COALESCE(NULLIF($2, ''), 'platform_' || replace(gen_random_uuid()::text, '-', '')), $3, $4, $5, $6, $7, $8, $9, NULLIF($10, '')::uuid, $11)
|
provider, platform_key, name, internal_name, base_url, auth_type, credentials, config,
|
||||||
RETURNING id::text, provider, platform_key, name, COALESCE(base_url, ''), auth_type, status, priority,
|
default_pricing_mode, default_discount_factor, pricing_rule_set_id,
|
||||||
default_pricing_mode, default_discount_factor::float8, COALESCE(pricing_rule_set_id::text, ''), config, created_at, updated_at`,
|
priority, retry_policy, rate_limit_policy
|
||||||
input.Provider, input.PlatformKey, input.Name, input.BaseURL, input.AuthType, credentials, config, input.DefaultPricingMode, input.DefaultDiscountFactor, input.PricingRuleSetID, input.Priority,
|
)
|
||||||
|
VALUES (
|
||||||
|
$1, COALESCE(NULLIF($2, ''), 'platform_' || replace(gen_random_uuid()::text, '-', '')), $3, NULLIF($4, ''), $5, $6, $7, $8,
|
||||||
|
$9, $10, NULLIF($11, '')::uuid, $12, $13, $14
|
||||||
|
)
|
||||||
|
RETURNING id::text, provider, platform_key, name, COALESCE(internal_name, ''), COALESCE(base_url, ''), auth_type, status, priority,
|
||||||
|
default_pricing_mode, default_discount_factor::float8, COALESCE(pricing_rule_set_id::text, ''),
|
||||||
|
config, credentials, retry_policy, rate_limit_policy, created_at, updated_at`,
|
||||||
|
input.Provider, input.PlatformKey, input.Name, strings.TrimSpace(input.InternalName), input.BaseURL, input.AuthType, credentials, config,
|
||||||
|
input.DefaultPricingMode, input.DefaultDiscountFactor, input.PricingRuleSetID, input.Priority,
|
||||||
|
string(retryPolicy), string(rateLimitPolicy),
|
||||||
).Scan(
|
).Scan(
|
||||||
&platform.ID,
|
&platform.ID,
|
||||||
&platform.Provider,
|
&platform.Provider,
|
||||||
&platform.PlatformKey,
|
&platform.PlatformKey,
|
||||||
&platform.Name,
|
&platform.Name,
|
||||||
|
&platform.InternalName,
|
||||||
&platform.BaseURL,
|
&platform.BaseURL,
|
||||||
&platform.AuthType,
|
&platform.AuthType,
|
||||||
&platform.Status,
|
&platform.Status,
|
||||||
@@ -406,6 +517,9 @@ RETURNING id::text, provider, platform_key, name, COALESCE(base_url, ''), auth_t
|
|||||||
&platform.DefaultDiscountFactor,
|
&platform.DefaultDiscountFactor,
|
||||||
&platform.PricingRuleSetID,
|
&platform.PricingRuleSetID,
|
||||||
&configBytes,
|
&configBytes,
|
||||||
|
&credentialsResultBytes,
|
||||||
|
&retryPolicyBytes,
|
||||||
|
&rateLimitPolicyBytes,
|
||||||
&platform.CreatedAt,
|
&platform.CreatedAt,
|
||||||
&platform.UpdatedAt,
|
&platform.UpdatedAt,
|
||||||
)
|
)
|
||||||
@@ -413,19 +527,143 @@ RETURNING id::text, provider, platform_key, name, COALESCE(base_url, ''), auth_t
|
|||||||
return Platform{}, err
|
return Platform{}, err
|
||||||
}
|
}
|
||||||
platform.Config = decodeObject(configBytes)
|
platform.Config = decodeObject(configBytes)
|
||||||
|
platform.CredentialsPreview = maskCredentialsPreview(credentialsResultBytes)
|
||||||
|
platform.RetryPolicy = decodeObject(retryPolicyBytes)
|
||||||
|
platform.RateLimitPolicy = decodeObject(rateLimitPolicyBytes)
|
||||||
return platform, nil
|
return platform, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdatePlatform(ctx context.Context, id string, input CreatePlatformInput) (Platform, error) {
|
||||||
|
var credentials any
|
||||||
|
if input.Credentials != nil {
|
||||||
|
credentialsBytes, _ := json.Marshal(emptyObjectIfNil(input.Credentials))
|
||||||
|
credentials = string(credentialsBytes)
|
||||||
|
}
|
||||||
|
config, _ := json.Marshal(emptyObjectIfNil(input.Config))
|
||||||
|
retryPolicy, _ := json.Marshal(emptyObjectIfNil(input.RetryPolicy))
|
||||||
|
rateLimitPolicy, _ := json.Marshal(emptyObjectIfNil(input.RateLimitPolicy))
|
||||||
|
if input.DefaultPricingMode == "" {
|
||||||
|
input.DefaultPricingMode = "inherit_discount"
|
||||||
|
}
|
||||||
|
if input.DefaultDiscountFactor == 0 {
|
||||||
|
input.DefaultDiscountFactor = 1
|
||||||
|
}
|
||||||
|
if input.Priority == 0 {
|
||||||
|
input.Priority = 100
|
||||||
|
}
|
||||||
|
var platform Platform
|
||||||
|
var configBytes []byte
|
||||||
|
var credentialsResultBytes []byte
|
||||||
|
var retryPolicyBytes []byte
|
||||||
|
var rateLimitPolicyBytes []byte
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
UPDATE integration_platforms
|
||||||
|
SET provider = $2,
|
||||||
|
platform_key = COALESCE(NULLIF($3, ''), platform_key),
|
||||||
|
name = $4,
|
||||||
|
internal_name = NULLIF($5, ''),
|
||||||
|
base_url = $6,
|
||||||
|
auth_type = $7,
|
||||||
|
credentials = CASE
|
||||||
|
WHEN $8::jsonb IS NULL THEN credentials
|
||||||
|
WHEN $8::jsonb = '{}'::jsonb THEN '{}'::jsonb
|
||||||
|
ELSE credentials || $8::jsonb
|
||||||
|
END,
|
||||||
|
config = $9,
|
||||||
|
default_pricing_mode = $10,
|
||||||
|
default_discount_factor = $11,
|
||||||
|
pricing_rule_set_id = NULLIF($12, '')::uuid,
|
||||||
|
priority = $13,
|
||||||
|
retry_policy = $14,
|
||||||
|
rate_limit_policy = $15,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1::uuid
|
||||||
|
RETURNING id::text, provider, platform_key, name, COALESCE(internal_name, ''), COALESCE(base_url, ''), auth_type, status, priority,
|
||||||
|
default_pricing_mode, default_discount_factor::float8, COALESCE(pricing_rule_set_id::text, ''),
|
||||||
|
config, credentials, retry_policy, rate_limit_policy, created_at, updated_at`,
|
||||||
|
id,
|
||||||
|
input.Provider,
|
||||||
|
input.PlatformKey,
|
||||||
|
input.Name,
|
||||||
|
strings.TrimSpace(input.InternalName),
|
||||||
|
input.BaseURL,
|
||||||
|
input.AuthType,
|
||||||
|
credentials,
|
||||||
|
string(config),
|
||||||
|
input.DefaultPricingMode,
|
||||||
|
input.DefaultDiscountFactor,
|
||||||
|
input.PricingRuleSetID,
|
||||||
|
input.Priority,
|
||||||
|
string(retryPolicy),
|
||||||
|
string(rateLimitPolicy),
|
||||||
|
).Scan(
|
||||||
|
&platform.ID,
|
||||||
|
&platform.Provider,
|
||||||
|
&platform.PlatformKey,
|
||||||
|
&platform.Name,
|
||||||
|
&platform.InternalName,
|
||||||
|
&platform.BaseURL,
|
||||||
|
&platform.AuthType,
|
||||||
|
&platform.Status,
|
||||||
|
&platform.Priority,
|
||||||
|
&platform.DefaultPricingMode,
|
||||||
|
&platform.DefaultDiscountFactor,
|
||||||
|
&platform.PricingRuleSetID,
|
||||||
|
&configBytes,
|
||||||
|
&credentialsResultBytes,
|
||||||
|
&retryPolicyBytes,
|
||||||
|
&rateLimitPolicyBytes,
|
||||||
|
&platform.CreatedAt,
|
||||||
|
&platform.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return Platform{}, err
|
||||||
|
}
|
||||||
|
platform.Config = decodeObject(configBytes)
|
||||||
|
platform.CredentialsPreview = maskCredentialsPreview(credentialsResultBytes)
|
||||||
|
platform.RetryPolicy = decodeObject(retryPolicyBytes)
|
||||||
|
platform.RateLimitPolicy = decodeObject(rateLimitPolicyBytes)
|
||||||
|
return platform, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeletePlatform(ctx context.Context, id string) error {
|
||||||
|
result, err := s.pool.Exec(ctx, `DELETE FROM integration_platforms WHERE id = $1::uuid`, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if result.RowsAffected() == 0 {
|
||||||
|
return pgx.ErrNoRows
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) ListModels(ctx context.Context) ([]PlatformModel, error) {
|
func (s *Store) ListModels(ctx context.Context) ([]PlatformModel, error) {
|
||||||
|
return s.listModels(ctx, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListPlatformModels(ctx context.Context, platformID string) ([]PlatformModel, error) {
|
||||||
|
return s.listModels(ctx, strings.TrimSpace(platformID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) listModels(ctx context.Context, platformID string) ([]PlatformModel, error) {
|
||||||
|
args := []any{}
|
||||||
|
where := ""
|
||||||
|
if platformID != "" {
|
||||||
|
where = "WHERE m.platform_id = $1::uuid"
|
||||||
|
args = append(args, platformID)
|
||||||
|
}
|
||||||
|
|
||||||
rows, err := s.pool.Query(ctx, `
|
rows, err := s.pool.Query(ctx, `
|
||||||
SELECT m.id::text, m.platform_id::text, COALESCE(m.base_model_id::text, ''), p.provider, p.name,
|
SELECT m.id::text, m.platform_id::text, COALESCE(m.base_model_id::text, ''), p.provider, p.name,
|
||||||
m.model_name, COALESCE(m.model_alias, ''), m.model_type, m.display_name,
|
m.model_name, COALESCE(m.model_alias, ''), m.model_type, m.display_name,
|
||||||
m.capability_override, m.capabilities, m.pricing_mode, COALESCE(m.discount_factor, 0)::float8,
|
m.capability_override, m.capabilities, m.pricing_mode, COALESCE(m.discount_factor, 0)::float8,
|
||||||
COALESCE(m.pricing_rule_set_id::text, ''), m.billing_config_override, m.billing_config,
|
COALESCE(m.pricing_rule_set_id::text, ''), m.billing_config_override, m.billing_config,
|
||||||
|
m.permission_config, m.retry_policy, m.rate_limit_policy, COALESCE(m.runtime_policy_set_id::text, ''), m.runtime_policy_override,
|
||||||
m.enabled, m.created_at, m.updated_at
|
m.enabled, m.created_at, m.updated_at
|
||||||
FROM platform_models m
|
FROM platform_models m
|
||||||
JOIN integration_platforms p ON p.id = m.platform_id
|
JOIN integration_platforms p ON p.id = m.platform_id
|
||||||
ORDER BY m.model_type ASC, m.model_name ASC`)
|
`+where+`
|
||||||
|
ORDER BY m.model_type ASC, m.model_name ASC`, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -438,6 +676,10 @@ ORDER BY m.model_type ASC, m.model_name ASC`)
|
|||||||
var capabilities []byte
|
var capabilities []byte
|
||||||
var billingConfigOverride []byte
|
var billingConfigOverride []byte
|
||||||
var billingConfig []byte
|
var billingConfig []byte
|
||||||
|
var permissionConfig []byte
|
||||||
|
var retryPolicy []byte
|
||||||
|
var rateLimitPolicy []byte
|
||||||
|
var runtimePolicyOverride []byte
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&model.ID,
|
&model.ID,
|
||||||
&model.PlatformID,
|
&model.PlatformID,
|
||||||
@@ -455,6 +697,11 @@ ORDER BY m.model_type ASC, m.model_name ASC`)
|
|||||||
&model.PricingRuleSetID,
|
&model.PricingRuleSetID,
|
||||||
&billingConfigOverride,
|
&billingConfigOverride,
|
||||||
&billingConfig,
|
&billingConfig,
|
||||||
|
&permissionConfig,
|
||||||
|
&retryPolicy,
|
||||||
|
&rateLimitPolicy,
|
||||||
|
&model.RuntimePolicySetID,
|
||||||
|
&runtimePolicyOverride,
|
||||||
&model.Enabled,
|
&model.Enabled,
|
||||||
&model.CreatedAt,
|
&model.CreatedAt,
|
||||||
&model.UpdatedAt,
|
&model.UpdatedAt,
|
||||||
@@ -465,6 +712,10 @@ ORDER BY m.model_type ASC, m.model_name ASC`)
|
|||||||
model.Capabilities = decodeObject(capabilities)
|
model.Capabilities = decodeObject(capabilities)
|
||||||
model.BillingConfigOverride = decodeObject(billingConfigOverride)
|
model.BillingConfigOverride = decodeObject(billingConfigOverride)
|
||||||
model.BillingConfig = decodeObject(billingConfig)
|
model.BillingConfig = decodeObject(billingConfig)
|
||||||
|
model.PermissionConfig = decodeObject(permissionConfig)
|
||||||
|
model.RetryPolicy = decodeObject(retryPolicy)
|
||||||
|
model.RateLimitPolicy = decodeObject(rateLimitPolicy)
|
||||||
|
model.RuntimePolicyOverride = decodeObject(runtimePolicyOverride)
|
||||||
models = append(models, model)
|
models = append(models, model)
|
||||||
}
|
}
|
||||||
return models, rows.Err()
|
return models, rows.Err()
|
||||||
@@ -731,6 +982,60 @@ ORDER BY created_at DESC`, gatewayUserID)
|
|||||||
return items, rows.Err()
|
return items, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListPlayableAPIKeys(ctx context.Context, user *auth.User) ([]PlayableAPIKey, error) {
|
||||||
|
items, err := s.listRecoverableAPIKeys(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(items) > 0 {
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
created, err := s.CreateAPIKey(ctx, CreateAPIKeyInput{
|
||||||
|
Name: "Playground API Key",
|
||||||
|
Scopes: []string{"chat", "image", "video"},
|
||||||
|
}, user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return []PlayableAPIKey{{APIKey: created.APIKey, Secret: created.Secret}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) listRecoverableAPIKeys(ctx context.Context, user *auth.User) ([]PlayableAPIKey, error) {
|
||||||
|
gatewayUserID := localGatewayUserID(user)
|
||||||
|
if gatewayUserID == "" {
|
||||||
|
return nil, ErrLocalUserRequired
|
||||||
|
}
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT id::text, COALESCE(gateway_tenant_id::text, ''), gateway_user_id::text,
|
||||||
|
COALESCE(tenant_id, ''), COALESCE(tenant_key, ''), COALESCE(user_id, ''),
|
||||||
|
key_prefix, name, scopes, COALESCE(user_group_id::text, ''),
|
||||||
|
rate_limit_policy, quota_policy, status, COALESCE(expires_at::text, ''),
|
||||||
|
COALESCE(last_used_at::text, ''), created_at, updated_at, COALESCE(key_secret, '')
|
||||||
|
FROM gateway_api_keys
|
||||||
|
WHERE gateway_user_id = $1::uuid
|
||||||
|
AND status = 'active'
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
AND COALESCE(key_secret, '') <> ''
|
||||||
|
AND (expires_at IS NULL OR expires_at > now())
|
||||||
|
ORDER BY created_at DESC`, gatewayUserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
items := make([]PlayableAPIKey, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var item PlayableAPIKey
|
||||||
|
apiKey, err := scanAPIKeyWithSecret(rows, &item.Secret)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
item.APIKey = apiKey
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
return items, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) CreateAPIKey(ctx context.Context, input CreateAPIKeyInput, user *auth.User) (CreatedAPIKey, error) {
|
func (s *Store) CreateAPIKey(ctx context.Context, input CreateAPIKeyInput, user *auth.User) (CreatedAPIKey, error) {
|
||||||
gatewayUserID := localGatewayUserID(user)
|
gatewayUserID := localGatewayUserID(user)
|
||||||
if gatewayUserID == "" {
|
if gatewayUserID == "" {
|
||||||
@@ -764,17 +1069,17 @@ func (s *Store) CreateAPIKey(ctx context.Context, input CreateAPIKeyInput, user
|
|||||||
err = s.pool.QueryRow(ctx, `
|
err = s.pool.QueryRow(ctx, `
|
||||||
INSERT INTO gateway_api_keys (
|
INSERT INTO gateway_api_keys (
|
||||||
gateway_tenant_id, gateway_user_id, tenant_id, tenant_key, user_id,
|
gateway_tenant_id, gateway_user_id, tenant_id, tenant_key, user_id,
|
||||||
key_prefix, key_hash, name, scopes, expires_at
|
key_prefix, key_secret, key_hash, name, scopes, expires_at
|
||||||
)
|
)
|
||||||
VALUES (NULLIF($1, '')::uuid, $2::uuid, NULLIF($3, ''), NULLIF($4, ''), NULLIF($5, ''),
|
VALUES (NULLIF($1, '')::uuid, $2::uuid, NULLIF($3, ''), NULLIF($4, ''), NULLIF($5, ''),
|
||||||
$6, $7, $8, $9::jsonb, NULLIF($10, '')::timestamptz)
|
$6, $7, $8, $9, $10::jsonb, NULLIF($11, '')::timestamptz)
|
||||||
RETURNING id::text, COALESCE(gateway_tenant_id::text, ''), gateway_user_id::text,
|
RETURNING id::text, COALESCE(gateway_tenant_id::text, ''), gateway_user_id::text,
|
||||||
COALESCE(tenant_id, ''), COALESCE(tenant_key, ''), COALESCE(user_id, ''),
|
COALESCE(tenant_id, ''), COALESCE(tenant_key, ''), COALESCE(user_id, ''),
|
||||||
key_prefix, name, scopes, COALESCE(user_group_id::text, ''),
|
key_prefix, name, scopes, COALESCE(user_group_id::text, ''),
|
||||||
rate_limit_policy, quota_policy, status, COALESCE(expires_at::text, ''),
|
rate_limit_policy, quota_policy, status, COALESCE(expires_at::text, ''),
|
||||||
COALESCE(last_used_at::text, ''), created_at, updated_at`,
|
COALESCE(last_used_at::text, ''), created_at, updated_at`,
|
||||||
user.GatewayTenantID, gatewayUserID, user.TenantID, user.TenantKey, user.ID,
|
user.GatewayTenantID, gatewayUserID, user.TenantID, user.TenantKey, user.ID,
|
||||||
apiKeyPrefix(secret), string(keyHash), name, string(scopesJSON), strings.TrimSpace(input.ExpiresAt),
|
apiKeyPrefix(secret), secret, string(keyHash), name, string(scopesJSON), strings.TrimSpace(input.ExpiresAt),
|
||||||
).Scan(
|
).Scan(
|
||||||
&item.ID,
|
&item.ID,
|
||||||
&item.GatewayTenantID,
|
&item.GatewayTenantID,
|
||||||
@@ -856,7 +1161,7 @@ func (s *Store) VerifyLocalAPIKey(ctx context.Context, secret string) (*auth.Use
|
|||||||
return nil, auth.ErrUnauthorized
|
return nil, auth.ErrUnauthorized
|
||||||
}
|
}
|
||||||
rows, err := s.pool.Query(ctx, `
|
rows, err := s.pool.Query(ctx, `
|
||||||
SELECT k.id::text, k.key_hash, COALESCE(k.user_group_id::text, ''),
|
SELECT k.id::text, k.key_hash, k.key_prefix, k.name, COALESCE(k.user_group_id::text, ''),
|
||||||
u.id::text, u.username, u.roles, COALESCE(u.gateway_tenant_id::text, ''),
|
u.id::text, u.username, u.roles, COALESCE(u.gateway_tenant_id::text, ''),
|
||||||
COALESCE(u.tenant_id, ''), COALESCE(u.tenant_key, '')
|
COALESCE(u.tenant_id, ''), COALESCE(u.tenant_key, '')
|
||||||
FROM gateway_api_keys k
|
FROM gateway_api_keys k
|
||||||
@@ -875,6 +1180,8 @@ WHERE k.key_prefix = $1
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var apiKeyID string
|
var apiKeyID string
|
||||||
var hash string
|
var hash string
|
||||||
|
var keyPrefix string
|
||||||
|
var keyName string
|
||||||
var userGroupID string
|
var userGroupID string
|
||||||
var gatewayUserID string
|
var gatewayUserID string
|
||||||
var username string
|
var username string
|
||||||
@@ -882,7 +1189,7 @@ WHERE k.key_prefix = $1
|
|||||||
var gatewayTenantID string
|
var gatewayTenantID string
|
||||||
var tenantID string
|
var tenantID string
|
||||||
var tenantKey string
|
var tenantKey string
|
||||||
if err := rows.Scan(&apiKeyID, &hash, &userGroupID, &gatewayUserID, &username, &rolesBytes, &gatewayTenantID, &tenantID, &tenantKey); err != nil {
|
if err := rows.Scan(&apiKeyID, &hash, &keyPrefix, &keyName, &userGroupID, &gatewayUserID, &username, &rolesBytes, &gatewayTenantID, &tenantID, &tenantKey); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(secret)) != nil {
|
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(secret)) != nil {
|
||||||
@@ -902,7 +1209,8 @@ WHERE k.key_prefix = $1
|
|||||||
GatewayUserID: gatewayUserID,
|
GatewayUserID: gatewayUserID,
|
||||||
UserGroupID: userGroupID,
|
UserGroupID: userGroupID,
|
||||||
APIKeyID: apiKeyID,
|
APIKeyID: apiKeyID,
|
||||||
APIKeyName: prefix,
|
APIKeyName: keyName,
|
||||||
|
APIKeyPrefix: keyPrefix,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
@@ -953,7 +1261,7 @@ SELECT NOT EXISTS (
|
|||||||
return GatewayUser{}, err
|
return GatewayUser{}, err
|
||||||
}
|
}
|
||||||
if isBootstrapUser {
|
if isBootstrapUser {
|
||||||
role = "admin"
|
role = "manager"
|
||||||
}
|
}
|
||||||
if err := tx.QueryRow(ctx, `
|
if err := tx.QueryRow(ctx, `
|
||||||
INSERT INTO gateway_tenants (tenant_key, source, external_tenant_id, name)
|
INSERT INTO gateway_tenants (tenant_key, source, external_tenant_id, name)
|
||||||
@@ -1197,21 +1505,16 @@ func (s *Store) CreateTask(ctx context.Context, input CreateTaskInput, user *aut
|
|||||||
}
|
}
|
||||||
defer tx.Rollback(ctx)
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
var task GatewayTask
|
task, err := scanGatewayTask(tx.QueryRow(ctx, `
|
||||||
var requestBytes []byte
|
|
||||||
var resultBytes []byte
|
|
||||||
var billingsBytes []byte
|
|
||||||
err = tx.QueryRow(ctx, `
|
|
||||||
INSERT INTO gateway_tasks (
|
INSERT INTO gateway_tasks (
|
||||||
kind, run_mode, user_id, gateway_user_id, user_source, gateway_tenant_id, tenant_id, tenant_key,
|
kind, run_mode, user_id, gateway_user_id, user_source, gateway_tenant_id, tenant_id, tenant_key,
|
||||||
api_key_id, user_group_id, user_group_key, model, request, status, result, billings, finished_at
|
api_key_id, api_key_name, api_key_prefix, user_group_id, user_group_key,
|
||||||
|
model, requested_model, request, status, result, billings, finished_at
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, NULLIF($4, '')::uuid, COALESCE(NULLIF($5, ''), 'gateway'), NULLIF($6, '')::uuid, NULLIF($7, ''), NULLIF($8, ''), NULLIF($9, ''), NULLIF($10, '')::uuid, NULLIF($11, ''), $12, $13, $14, $15::jsonb, $16::jsonb, CASE WHEN $17 THEN now() ELSE NULL END)
|
VALUES ($1, $2, $3, NULLIF($4, '')::uuid, COALESCE(NULLIF($5, ''), 'gateway'), NULLIF($6, '')::uuid, NULLIF($7, ''), NULLIF($8, ''), NULLIF($9, ''), NULLIF($10, ''), NULLIF($11, ''), NULLIF($12, '')::uuid, NULLIF($13, ''), $14, $14, $15, $16, $17::jsonb, $18::jsonb, CASE WHEN $19 THEN now() ELSE NULL END)
|
||||||
RETURNING id::text, kind, run_mode, user_id, COALESCE(gateway_user_id::text, ''), user_source,
|
RETURNING `+gatewayTaskColumns,
|
||||||
COALESCE(gateway_tenant_id::text, ''), COALESCE(tenant_id, ''), COALESCE(tenant_key, ''),
|
input.Kind, runMode, user.ID, user.GatewayUserID, user.Source, user.GatewayTenantID, user.TenantID, user.TenantKey, user.APIKeyID, user.APIKeyName, user.APIKeyPrefix, user.UserGroupID, user.UserGroupKey, input.Model, requestBody, status, resultBody, billingsBody, false,
|
||||||
COALESCE(user_group_id::text, ''), COALESCE(user_group_key, ''), model, request, status, result, billings, COALESCE(error, ''), created_at, updated_at`,
|
))
|
||||||
input.Kind, runMode, user.ID, user.GatewayUserID, user.Source, user.GatewayTenantID, user.TenantID, user.TenantKey, user.APIKeyID, user.UserGroupID, user.UserGroupKey, input.Model, requestBody, status, resultBody, billingsBody, false,
|
|
||||||
).Scan(&task.ID, &task.Kind, &task.RunMode, &task.UserID, &task.GatewayUserID, &task.UserSource, &task.GatewayTenantID, &task.TenantID, &task.TenantKey, &task.UserGroupID, &task.UserGroupKey, &task.Model, &requestBytes, &task.Status, &resultBytes, &billingsBytes, &task.Error, &task.CreatedAt, &task.UpdatedAt)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return GatewayTask{}, err
|
return GatewayTask{}, err
|
||||||
}
|
}
|
||||||
@@ -1229,30 +1532,77 @@ VALUES ($1::uuid, $2, $3, NULLIF($4, ''), NULLIF($5, ''), $6, NULLIF($7, ''), $8
|
|||||||
if err := tx.Commit(ctx); err != nil {
|
if err := tx.Commit(ctx); err != nil {
|
||||||
return GatewayTask{}, err
|
return GatewayTask{}, err
|
||||||
}
|
}
|
||||||
task.Request = decodeObject(requestBytes)
|
|
||||||
task.Result = decodeObject(resultBytes)
|
|
||||||
task.Billings = decodeArray(billingsBytes)
|
|
||||||
return task, nil
|
return task, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) GetTask(ctx context.Context, taskID string) (GatewayTask, error) {
|
func (s *Store) GetTask(ctx context.Context, taskID string) (GatewayTask, error) {
|
||||||
|
task, err := scanGatewayTask(s.pool.QueryRow(ctx, `
|
||||||
|
SELECT `+gatewayTaskColumns+`
|
||||||
|
FROM gateway_tasks
|
||||||
|
WHERE id=$1`, taskID,
|
||||||
|
))
|
||||||
|
if err != nil {
|
||||||
|
return GatewayTask{}, err
|
||||||
|
}
|
||||||
|
return task, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type taskScanner interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanGatewayTask(scanner taskScanner) (GatewayTask, error) {
|
||||||
var task GatewayTask
|
var task GatewayTask
|
||||||
var requestBytes []byte
|
var requestBytes []byte
|
||||||
var resultBytes []byte
|
var resultBytes []byte
|
||||||
var billingsBytes []byte
|
var billingsBytes []byte
|
||||||
err := s.pool.QueryRow(ctx, `
|
var usageBytes []byte
|
||||||
SELECT id::text, kind, run_mode, user_id, COALESCE(gateway_user_id::text, ''), user_source,
|
var metricsBytes []byte
|
||||||
COALESCE(gateway_tenant_id::text, ''), COALESCE(tenant_id, ''), COALESCE(tenant_key, ''),
|
var billingSummaryBytes []byte
|
||||||
COALESCE(user_group_id::text, ''), COALESCE(user_group_key, ''), model, request, status, result, billings, COALESCE(error, ''), created_at, updated_at
|
if err := scanner.Scan(
|
||||||
FROM gateway_tasks
|
&task.ID,
|
||||||
WHERE id=$1`, taskID,
|
&task.Kind,
|
||||||
).Scan(&task.ID, &task.Kind, &task.RunMode, &task.UserID, &task.GatewayUserID, &task.UserSource, &task.GatewayTenantID, &task.TenantID, &task.TenantKey, &task.UserGroupID, &task.UserGroupKey, &task.Model, &requestBytes, &task.Status, &resultBytes, &billingsBytes, &task.Error, &task.CreatedAt, &task.UpdatedAt)
|
&task.RunMode,
|
||||||
if err != nil {
|
&task.UserID,
|
||||||
|
&task.GatewayUserID,
|
||||||
|
&task.UserSource,
|
||||||
|
&task.GatewayTenantID,
|
||||||
|
&task.TenantID,
|
||||||
|
&task.TenantKey,
|
||||||
|
&task.APIKeyID,
|
||||||
|
&task.APIKeyName,
|
||||||
|
&task.APIKeyPrefix,
|
||||||
|
&task.UserGroupID,
|
||||||
|
&task.UserGroupKey,
|
||||||
|
&task.Model,
|
||||||
|
&task.ModelType,
|
||||||
|
&task.RequestedModel,
|
||||||
|
&task.ResolvedModel,
|
||||||
|
&task.RequestID,
|
||||||
|
&requestBytes,
|
||||||
|
&task.Status,
|
||||||
|
&resultBytes,
|
||||||
|
&billingsBytes,
|
||||||
|
&usageBytes,
|
||||||
|
&metricsBytes,
|
||||||
|
&billingSummaryBytes,
|
||||||
|
&task.FinalChargeAmount,
|
||||||
|
&task.ResponseStartedAt,
|
||||||
|
&task.ResponseFinishedAt,
|
||||||
|
&task.ResponseDurationMS,
|
||||||
|
&task.Error,
|
||||||
|
&task.CreatedAt,
|
||||||
|
&task.UpdatedAt,
|
||||||
|
&task.FinishedAt,
|
||||||
|
); err != nil {
|
||||||
return GatewayTask{}, err
|
return GatewayTask{}, err
|
||||||
}
|
}
|
||||||
task.Request = decodeObject(requestBytes)
|
task.Request = decodeObject(requestBytes)
|
||||||
task.Result = decodeObject(resultBytes)
|
task.Result = decodeObject(resultBytes)
|
||||||
task.Billings = decodeArray(billingsBytes)
|
task.Billings = decodeArray(billingsBytes)
|
||||||
|
task.Usage = decodeObject(usageBytes)
|
||||||
|
task.Metrics = decodeObject(metricsBytes)
|
||||||
|
task.BillingSummary = decodeObject(billingSummaryBytes)
|
||||||
return task, nil
|
return task, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1342,6 +1692,39 @@ func scanAPIKey(scanner apiKeyScanner) (APIKey, error) {
|
|||||||
return item, nil
|
return item, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func scanAPIKeyWithSecret(scanner apiKeyScanner, secret *string) (APIKey, error) {
|
||||||
|
var item APIKey
|
||||||
|
var scopesBytes []byte
|
||||||
|
var rateLimitPolicy []byte
|
||||||
|
var quotaPolicy []byte
|
||||||
|
if err := scanner.Scan(
|
||||||
|
&item.ID,
|
||||||
|
&item.GatewayTenantID,
|
||||||
|
&item.GatewayUserID,
|
||||||
|
&item.TenantID,
|
||||||
|
&item.TenantKey,
|
||||||
|
&item.UserID,
|
||||||
|
&item.KeyPrefix,
|
||||||
|
&item.Name,
|
||||||
|
&scopesBytes,
|
||||||
|
&item.UserGroupID,
|
||||||
|
&rateLimitPolicy,
|
||||||
|
"aPolicy,
|
||||||
|
&item.Status,
|
||||||
|
&item.ExpiresAt,
|
||||||
|
&item.LastUsedAt,
|
||||||
|
&item.CreatedAt,
|
||||||
|
&item.UpdatedAt,
|
||||||
|
secret,
|
||||||
|
); err != nil {
|
||||||
|
return APIKey{}, err
|
||||||
|
}
|
||||||
|
item.Scopes = decodeStringArray(scopesBytes)
|
||||||
|
item.RateLimitPolicy = decodeObject(rateLimitPolicy)
|
||||||
|
item.QuotaPolicy = decodeObject(quotaPolicy)
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
func localGatewayUserID(user *auth.User) string {
|
func localGatewayUserID(user *auth.User) string {
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return ""
|
return ""
|
||||||
@@ -1477,6 +1860,50 @@ func decodeObject(bytes []byte) map[string]any {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func maskCredentialsPreview(bytes []byte) map[string]any {
|
||||||
|
credentials := decodeObject(bytes)
|
||||||
|
if len(credentials) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]any, len(credentials))
|
||||||
|
for key, value := range credentials {
|
||||||
|
out[key] = maskCredentialValue(value)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func maskCredentialValue(value any) any {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case string:
|
||||||
|
return maskSecret(typed)
|
||||||
|
case map[string]any:
|
||||||
|
out := make(map[string]any, len(typed))
|
||||||
|
for key, nested := range typed {
|
||||||
|
out[key] = maskCredentialValue(nested)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case []any:
|
||||||
|
out := make([]any, 0, len(typed))
|
||||||
|
for _, nested := range typed {
|
||||||
|
out = append(out, maskCredentialValue(nested))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
default:
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func maskSecret(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(value) <= 6 {
|
||||||
|
return strings.Repeat("*", len(value))
|
||||||
|
}
|
||||||
|
return value[:3] + strings.Repeat("*", len(value)-6) + value[len(value)-3:]
|
||||||
|
}
|
||||||
|
|
||||||
func decodeArray(bytes []byte) []any {
|
func decodeArray(bytes []byte) []any {
|
||||||
if len(bytes) == 0 {
|
if len(bytes) == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package store
|
package store
|
||||||
|
|
||||||
import "errors"
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrNoModelCandidate = errors.New("no enabled platform model matches request")
|
ErrNoModelCandidate = errors.New("no enabled platform model matches request")
|
||||||
@@ -25,6 +28,8 @@ type CreatePlatformModelInput struct {
|
|||||||
PermissionConfig map[string]any `json:"permissionConfig"`
|
PermissionConfig map[string]any `json:"permissionConfig"`
|
||||||
RetryPolicy map[string]any `json:"retryPolicy"`
|
RetryPolicy map[string]any `json:"retryPolicy"`
|
||||||
RateLimitPolicy map[string]any `json:"rateLimitPolicy"`
|
RateLimitPolicy map[string]any `json:"rateLimitPolicy"`
|
||||||
|
RuntimePolicySetID string `json:"runtimePolicySetId"`
|
||||||
|
RuntimePolicyOverride map[string]any `json:"runtimePolicyOverride"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +38,7 @@ type RuntimeModelCandidate struct {
|
|||||||
PlatformKey string
|
PlatformKey string
|
||||||
PlatformName string
|
PlatformName string
|
||||||
Provider string
|
Provider string
|
||||||
|
SpecType string
|
||||||
BaseURL string
|
BaseURL string
|
||||||
AuthType string
|
AuthType string
|
||||||
Credentials map[string]any
|
Credentials map[string]any
|
||||||
@@ -55,12 +61,15 @@ type RuntimeModelCandidate struct {
|
|||||||
BaseBillingConfig map[string]any
|
BaseBillingConfig map[string]any
|
||||||
BillingConfig map[string]any
|
BillingConfig map[string]any
|
||||||
BillingConfigOverride map[string]any
|
BillingConfigOverride map[string]any
|
||||||
|
PermissionConfig map[string]any
|
||||||
PricingMode string
|
PricingMode string
|
||||||
DiscountFactor float64
|
DiscountFactor float64
|
||||||
PlatformPricingRuleSetID string
|
PlatformPricingRuleSetID string
|
||||||
ModelPricingRuleSetID string
|
ModelPricingRuleSetID string
|
||||||
ModelRetryPolicy map[string]any
|
ModelRetryPolicy map[string]any
|
||||||
ModelRateLimitPolicy map[string]any
|
ModelRateLimitPolicy map[string]any
|
||||||
|
RuntimePolicySetID string
|
||||||
|
RuntimePolicyOverride map[string]any
|
||||||
ClientID string
|
ClientID string
|
||||||
QueueKey string
|
QueueKey string
|
||||||
}
|
}
|
||||||
@@ -92,10 +101,42 @@ type CreateTaskAttemptInput struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type FinishTaskAttemptInput struct {
|
type FinishTaskAttemptInput struct {
|
||||||
AttemptID string
|
AttemptID string
|
||||||
Status string
|
Status string
|
||||||
Retryable bool
|
Retryable bool
|
||||||
ResponseSnapshot map[string]any
|
RequestID string
|
||||||
ErrorCode string
|
Usage map[string]any
|
||||||
ErrorMessage string
|
Metrics map[string]any
|
||||||
|
ResponseSnapshot map[string]any
|
||||||
|
ResponseStartedAt time.Time
|
||||||
|
ResponseFinishedAt time.Time
|
||||||
|
ResponseDurationMS int64
|
||||||
|
ErrorCode string
|
||||||
|
ErrorMessage string
|
||||||
|
}
|
||||||
|
|
||||||
|
type FinishTaskSuccessInput struct {
|
||||||
|
TaskID string
|
||||||
|
Result map[string]any
|
||||||
|
Billings []any
|
||||||
|
RequestID string
|
||||||
|
ResolvedModel string
|
||||||
|
Usage map[string]any
|
||||||
|
Metrics map[string]any
|
||||||
|
BillingSummary map[string]any
|
||||||
|
FinalChargeAmount float64
|
||||||
|
ResponseStartedAt time.Time
|
||||||
|
ResponseFinishedAt time.Time
|
||||||
|
ResponseDurationMS int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type FinishTaskFailureInput struct {
|
||||||
|
TaskID string
|
||||||
|
Code string
|
||||||
|
Message string
|
||||||
|
RequestID string
|
||||||
|
Metrics map[string]any
|
||||||
|
ResponseStartedAt time.Time
|
||||||
|
ResponseFinishedAt time.Time
|
||||||
|
ResponseDurationMS int64
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package store
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Store) MarkTaskRunning(ctx context.Context, taskID string, modelType string, normalizedRequest map[string]any) error {
|
func (s *Store) MarkTaskRunning(ctx context.Context, taskID string, modelType string, normalizedRequest map[string]any) error {
|
||||||
@@ -62,57 +63,118 @@ WHERE id = $1::uuid`, input.TaskID, input.AttemptNo); err != nil {
|
|||||||
|
|
||||||
func (s *Store) FinishTaskAttempt(ctx context.Context, input FinishTaskAttemptInput) error {
|
func (s *Store) FinishTaskAttempt(ctx context.Context, input FinishTaskAttemptInput) error {
|
||||||
responseJSON, _ := json.Marshal(emptyObjectIfNil(input.ResponseSnapshot))
|
responseJSON, _ := json.Marshal(emptyObjectIfNil(input.ResponseSnapshot))
|
||||||
|
usageJSON, _ := json.Marshal(emptyObjectIfNil(input.Usage))
|
||||||
|
metricsJSON, _ := json.Marshal(emptyObjectIfNil(input.Metrics))
|
||||||
_, err := s.pool.Exec(ctx, `
|
_, err := s.pool.Exec(ctx, `
|
||||||
UPDATE gateway_task_attempts
|
UPDATE gateway_task_attempts
|
||||||
SET status = $2,
|
SET status = $2,
|
||||||
retryable = $3,
|
retryable = $3,
|
||||||
response_snapshot = $4::jsonb,
|
request_id = NULLIF($4, ''),
|
||||||
error_code = NULLIF($5, ''),
|
usage = $5::jsonb,
|
||||||
error_message = NULLIF($6, ''),
|
metrics = $6::jsonb,
|
||||||
|
response_snapshot = $7::jsonb,
|
||||||
|
response_started_at = $8::timestamptz,
|
||||||
|
response_finished_at = $9::timestamptz,
|
||||||
|
response_duration_ms = $10,
|
||||||
|
error_code = NULLIF($11, ''),
|
||||||
|
error_message = NULLIF($12, ''),
|
||||||
finished_at = now()
|
finished_at = now()
|
||||||
WHERE id = $1::uuid`,
|
WHERE id = $1::uuid`,
|
||||||
input.AttemptID,
|
input.AttemptID,
|
||||||
input.Status,
|
input.Status,
|
||||||
input.Retryable,
|
input.Retryable,
|
||||||
|
input.RequestID,
|
||||||
|
string(usageJSON),
|
||||||
|
string(metricsJSON),
|
||||||
string(responseJSON),
|
string(responseJSON),
|
||||||
|
nullableTime(input.ResponseStartedAt),
|
||||||
|
nullableTime(input.ResponseFinishedAt),
|
||||||
|
input.ResponseDurationMS,
|
||||||
input.ErrorCode,
|
input.ErrorCode,
|
||||||
input.ErrorMessage,
|
input.ErrorMessage,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) FinishTaskSuccess(ctx context.Context, taskID string, result map[string]any, billings []any) (GatewayTask, error) {
|
func (s *Store) FinishTaskSuccess(ctx context.Context, input FinishTaskSuccessInput) (GatewayTask, error) {
|
||||||
resultJSON, _ := json.Marshal(emptyObjectIfNil(result))
|
resultJSON, _ := json.Marshal(emptyObjectIfNil(input.Result))
|
||||||
billingsJSON, _ := json.Marshal(billings)
|
billingsJSON, _ := json.Marshal(input.Billings)
|
||||||
|
usageJSON, _ := json.Marshal(emptyObjectIfNil(input.Usage))
|
||||||
|
metricsJSON, _ := json.Marshal(emptyObjectIfNil(input.Metrics))
|
||||||
|
billingSummaryJSON, _ := json.Marshal(emptyObjectIfNil(input.BillingSummary))
|
||||||
if _, err := s.pool.Exec(ctx, `
|
if _, err := s.pool.Exec(ctx, `
|
||||||
UPDATE gateway_tasks
|
UPDATE gateway_tasks
|
||||||
SET status = 'succeeded',
|
SET status = 'succeeded',
|
||||||
result = $2::jsonb,
|
result = $2::jsonb,
|
||||||
billings = $3::jsonb,
|
billings = $3::jsonb,
|
||||||
|
request_id = NULLIF($4, ''),
|
||||||
|
resolved_model = NULLIF($5, ''),
|
||||||
|
usage = $6::jsonb,
|
||||||
|
metrics = $7::jsonb,
|
||||||
|
billing_summary = $8::jsonb,
|
||||||
|
final_charge_amount = $9,
|
||||||
|
response_started_at = $10::timestamptz,
|
||||||
|
response_finished_at = $11::timestamptz,
|
||||||
|
response_duration_ms = $12,
|
||||||
error = NULL,
|
error = NULL,
|
||||||
error_code = NULL,
|
error_code = NULL,
|
||||||
error_message = NULL,
|
error_message = NULL,
|
||||||
finished_at = now(),
|
finished_at = now(),
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $1::uuid`, taskID, string(resultJSON), string(billingsJSON)); err != nil {
|
WHERE id = $1::uuid`,
|
||||||
|
input.TaskID,
|
||||||
|
string(resultJSON),
|
||||||
|
string(billingsJSON),
|
||||||
|
input.RequestID,
|
||||||
|
input.ResolvedModel,
|
||||||
|
string(usageJSON),
|
||||||
|
string(metricsJSON),
|
||||||
|
string(billingSummaryJSON),
|
||||||
|
input.FinalChargeAmount,
|
||||||
|
nullableTime(input.ResponseStartedAt),
|
||||||
|
nullableTime(input.ResponseFinishedAt),
|
||||||
|
input.ResponseDurationMS,
|
||||||
|
); err != nil {
|
||||||
return GatewayTask{}, err
|
return GatewayTask{}, err
|
||||||
}
|
}
|
||||||
return s.GetTask(ctx, taskID)
|
return s.GetTask(ctx, input.TaskID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) FinishTaskFailure(ctx context.Context, taskID string, code string, message string) (GatewayTask, error) {
|
func (s *Store) FinishTaskFailure(ctx context.Context, input FinishTaskFailureInput) (GatewayTask, error) {
|
||||||
|
metricsJSON, _ := json.Marshal(emptyObjectIfNil(input.Metrics))
|
||||||
if _, err := s.pool.Exec(ctx, `
|
if _, err := s.pool.Exec(ctx, `
|
||||||
UPDATE gateway_tasks
|
UPDATE gateway_tasks
|
||||||
SET status = 'failed',
|
SET status = 'failed',
|
||||||
error = NULLIF($2, ''),
|
error = NULLIF($2, ''),
|
||||||
error_code = NULLIF($3, ''),
|
error_code = NULLIF($3, ''),
|
||||||
error_message = NULLIF($2, ''),
|
error_message = NULLIF($2, ''),
|
||||||
|
request_id = NULLIF($4, ''),
|
||||||
|
metrics = $5::jsonb,
|
||||||
|
response_started_at = $6::timestamptz,
|
||||||
|
response_finished_at = $7::timestamptz,
|
||||||
|
response_duration_ms = $8,
|
||||||
finished_at = now(),
|
finished_at = now(),
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $1::uuid`, taskID, message, code); err != nil {
|
WHERE id = $1::uuid`,
|
||||||
|
input.TaskID,
|
||||||
|
input.Message,
|
||||||
|
input.Code,
|
||||||
|
input.RequestID,
|
||||||
|
string(metricsJSON),
|
||||||
|
nullableTime(input.ResponseStartedAt),
|
||||||
|
nullableTime(input.ResponseFinishedAt),
|
||||||
|
input.ResponseDurationMS,
|
||||||
|
); err != nil {
|
||||||
return GatewayTask{}, err
|
return GatewayTask{}, err
|
||||||
}
|
}
|
||||||
return s.GetTask(ctx, taskID)
|
return s.GetTask(ctx, input.TaskID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullableTime(value time.Time) any {
|
||||||
|
if value.IsZero() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) AddTaskEvent(ctx context.Context, taskID string, eventType string, status string, phase string, progress float64, message string, payload map[string]any, simulated bool) (TaskEvent, error) {
|
func (s *Store) AddTaskEvent(ctx context.Context, taskID string, eventType string, status string, phase string, progress float64, message string, payload map[string]any, simulated bool) (TaskEvent, error) {
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
ALTER TABLE IF EXISTS gateway_tasks
|
||||||
|
ADD COLUMN IF NOT EXISTS api_key_name text,
|
||||||
|
ADD COLUMN IF NOT EXISTS api_key_prefix text,
|
||||||
|
ADD COLUMN IF NOT EXISTS requested_model text,
|
||||||
|
ADD COLUMN IF NOT EXISTS resolved_model text,
|
||||||
|
ADD COLUMN IF NOT EXISTS request_id text,
|
||||||
|
ADD COLUMN IF NOT EXISTS usage jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
ADD COLUMN IF NOT EXISTS metrics jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
ADD COLUMN IF NOT EXISTS billing_summary jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
ADD COLUMN IF NOT EXISTS final_charge_amount numeric NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS response_started_at timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS response_finished_at timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS response_duration_ms bigint;
|
||||||
|
|
||||||
|
ALTER TABLE IF EXISTS gateway_task_attempts
|
||||||
|
ADD COLUMN IF NOT EXISTS request_id text,
|
||||||
|
ADD COLUMN IF NOT EXISTS usage jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
ADD COLUMN IF NOT EXISTS metrics jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
ADD COLUMN IF NOT EXISTS response_started_at timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS response_finished_at timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS response_duration_ms bigint;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gateway_tasks_request_id
|
||||||
|
ON gateway_tasks(request_id)
|
||||||
|
WHERE request_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gateway_tasks_api_key_created
|
||||||
|
ON gateway_tasks(api_key_id, created_at DESC)
|
||||||
|
WHERE api_key_id IS NOT NULL;
|
||||||
|
|
||||||
|
UPDATE gateway_tasks
|
||||||
|
SET requested_model = model
|
||||||
|
WHERE requested_model IS NULL;
|
||||||
|
|
||||||
|
UPDATE gateway_tasks AS t
|
||||||
|
SET api_key_name = COALESCE(t.api_key_name, k.name),
|
||||||
|
api_key_prefix = COALESCE(t.api_key_prefix, k.key_prefix)
|
||||||
|
FROM gateway_api_keys AS k
|
||||||
|
WHERE t.api_key_id = k.id::text;
|
||||||
|
|
||||||
|
WITH billing_totals AS (
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
COUNT(line.value) AS line_count,
|
||||||
|
COALESCE(SUM(
|
||||||
|
CASE
|
||||||
|
WHEN jsonb_typeof(line.value) = 'object'
|
||||||
|
AND line.value ? 'amount'
|
||||||
|
AND line.value->>'amount' ~ '^-?[0-9]+(\.[0-9]+)?$'
|
||||||
|
THEN (line.value->>'amount')::numeric
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
), 0) AS total_amount
|
||||||
|
FROM gateway_tasks AS t
|
||||||
|
LEFT JOIN LATERAL jsonb_array_elements(COALESCE(t.billings, '[]'::jsonb)) AS line(value) ON true
|
||||||
|
GROUP BY t.id
|
||||||
|
)
|
||||||
|
UPDATE gateway_tasks AS t
|
||||||
|
SET final_charge_amount = billing_totals.total_amount,
|
||||||
|
billing_summary = jsonb_build_object(
|
||||||
|
'lineCount', billing_totals.line_count,
|
||||||
|
'totalAmount', billing_totals.total_amount,
|
||||||
|
'currency', 'resource',
|
||||||
|
'finalCharge', jsonb_build_object(
|
||||||
|
'amount', billing_totals.total_amount,
|
||||||
|
'currency', 'resource',
|
||||||
|
'simulated', COALESCE(t.run_mode = 'simulation', false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
FROM billing_totals
|
||||||
|
WHERE t.id = billing_totals.id
|
||||||
|
AND COALESCE(t.billing_summary, '{}'::jsonb) = '{}'::jsonb;
|
||||||
@@ -2,7 +2,6 @@ import type { FormEvent, ReactNode } from 'react';
|
|||||||
import { CreditCard, KeyRound, ListChecks, UserRound } from 'lucide-react';
|
import { CreditCard, KeyRound, ListChecks, UserRound } from 'lucide-react';
|
||||||
import type { ConsoleData } from '../app-state';
|
import type { ConsoleData } from '../app-state';
|
||||||
import { EntityTable } from '../components/EntityTable';
|
import { EntityTable } from '../components/EntityTable';
|
||||||
import { PageHeader } from '../components/PageHeader';
|
|
||||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Input, Label, Tabs } from '../components/ui';
|
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Input, Label, Tabs } from '../components/ui';
|
||||||
import type { ApiKeyForm, LoadState, WorkspaceSection } from '../types';
|
import type { ApiKeyForm, LoadState, WorkspaceSection } from '../types';
|
||||||
|
|
||||||
@@ -22,10 +21,10 @@ export function WorkspacePage(props: {
|
|||||||
onApiKeyFormChange: (value: ApiKeyForm) => void;
|
onApiKeyFormChange: (value: ApiKeyForm) => void;
|
||||||
onSectionChange: (value: WorkspaceSection) => void;
|
onSectionChange: (value: WorkspaceSection) => void;
|
||||||
onSubmitApiKey: (event: FormEvent<HTMLFormElement>) => void;
|
onSubmitApiKey: (event: FormEvent<HTMLFormElement>) => void;
|
||||||
|
onUseApiKeyForPlayground: (apiKeyId?: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="pageStack">
|
<div className="pageStack">
|
||||||
<PageHeader eyebrow="Workspace" title="用户工作台" description="个人资产、API Key 和任务记录。" />
|
|
||||||
<div className="subPageLayout">
|
<div className="subPageLayout">
|
||||||
<Tabs className="sideTabs" value={props.section} tabs={tabs} onValueChange={props.onSectionChange} />
|
<Tabs className="sideTabs" value={props.section} tabs={tabs} onValueChange={props.onSectionChange} />
|
||||||
<div className="subPageContent">
|
<div className="subPageContent">
|
||||||
@@ -106,7 +105,9 @@ function ApiKeyPanel(props: {
|
|||||||
state: LoadState;
|
state: LoadState;
|
||||||
onApiKeyFormChange: (value: ApiKeyForm) => void;
|
onApiKeyFormChange: (value: ApiKeyForm) => void;
|
||||||
onSubmitApiKey: (event: FormEvent<HTMLFormElement>) => void;
|
onSubmitApiKey: (event: FormEvent<HTMLFormElement>) => void;
|
||||||
|
onUseApiKeyForPlayground: (apiKeyId?: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const latestUsableKey = props.apiKeySecret ? props.data.apiKeys[0] : undefined;
|
||||||
return (
|
return (
|
||||||
<section className="contentGrid two">
|
<section className="contentGrid two">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -123,7 +124,14 @@ function ApiKeyPanel(props: {
|
|||||||
<KeyRound size={15} />
|
<KeyRound size={15} />
|
||||||
创建
|
创建
|
||||||
</Button>
|
</Button>
|
||||||
{props.apiKeySecret && <code className="secretBox">{props.apiKeySecret}</code>}
|
{props.apiKeySecret && (
|
||||||
|
<div className="createdApiKeyBox">
|
||||||
|
<code className="secretBox">{props.apiKeySecret}</code>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => props.onUseApiKeyForPlayground(latestUsableKey?.id)}>
|
||||||
|
使用测试
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -145,6 +153,9 @@ function ApiKeyPanel(props: {
|
|||||||
|
|
||||||
function TaskPanel(props: { data: ConsoleData }) {
|
function TaskPanel(props: { data: ConsoleData }) {
|
||||||
const task = props.data.taskResult;
|
const task = props.data.taskResult;
|
||||||
|
const usage = task?.usage ?? {};
|
||||||
|
const tokenText = usage.totalTokens ? `${usage.totalTokens}` : '-';
|
||||||
|
const chargeText = task?.finalChargeAmount ? `${task.finalChargeAmount}` : '-';
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -156,7 +167,15 @@ function TaskPanel(props: { data: ConsoleData }) {
|
|||||||
<Badge variant={task.status === 'succeeded' ? 'success' : 'secondary'}>{task.status}</Badge>
|
<Badge variant={task.status === 'succeeded' ? 'success' : 'secondary'}>{task.status}</Badge>
|
||||||
<strong>{task.kind}</strong>
|
<strong>{task.kind}</strong>
|
||||||
<span>{task.model}</span>
|
<span>{task.model}</span>
|
||||||
<pre>{JSON.stringify(task.result ?? {}, null, 2)}</pre>
|
<div className="infoGrid compact">
|
||||||
|
<InfoItem label="API Key" value={task.apiKeyName || task.apiKeyId || '-'} />
|
||||||
|
<InfoItem label="RequestID" value={task.requestId || '-'} />
|
||||||
|
<InfoItem label="实际模型" value={task.resolvedModel || task.model} />
|
||||||
|
<InfoItem label="Token" value={tokenText} />
|
||||||
|
<InfoItem label="扣费" value={chargeText} />
|
||||||
|
<InfoItem label="响应耗时" value={task.responseDurationMs ? `${task.responseDurationMs}ms` : '-'} />
|
||||||
|
</div>
|
||||||
|
<pre>{JSON.stringify({ result: task.result, usage: task.usage, billings: task.billings, billingSummary: task.billingSummary, metrics: task.metrics }, null, 2)}</pre>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="emptyState">
|
<div className="emptyState">
|
||||||
|
|||||||
@@ -407,6 +407,16 @@ strong {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.infoGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infoGrid.compact .infoItem {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.spanTwo {
|
.spanTwo {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface AuthUser {
|
|||||||
apiKeyId?: string;
|
apiKeyId?: string;
|
||||||
apiKeySecret?: string;
|
apiKeySecret?: string;
|
||||||
apiKeyName?: string;
|
apiKeyName?: string;
|
||||||
|
apiKeyPrefix?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
@@ -56,6 +57,7 @@ export interface IntegrationPlatform {
|
|||||||
provider: string;
|
provider: string;
|
||||||
platformKey: string;
|
platformKey: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
internalName?: string;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
authType: string;
|
authType: string;
|
||||||
status: 'enabled' | 'disabled' | string;
|
status: 'enabled' | 'disabled' | string;
|
||||||
@@ -64,6 +66,9 @@ export interface IntegrationPlatform {
|
|||||||
defaultDiscountFactor: number;
|
defaultDiscountFactor: number;
|
||||||
pricingRuleSetId?: string;
|
pricingRuleSetId?: string;
|
||||||
config?: Record<string, unknown>;
|
config?: Record<string, unknown>;
|
||||||
|
credentialsPreview?: Record<string, unknown>;
|
||||||
|
retryPolicy?: RateLimitPolicy | Record<string, unknown>;
|
||||||
|
rateLimitPolicy?: RateLimitPolicy;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -75,6 +80,7 @@ export type RateLimitMetric =
|
|||||||
| 'tpm_input'
|
| 'tpm_input'
|
||||||
| 'tpm_output'
|
| 'tpm_output'
|
||||||
| 'rpm'
|
| 'rpm'
|
||||||
|
| 'rps'
|
||||||
| 'concurrent'
|
| 'concurrent'
|
||||||
| 'queue_size';
|
| 'queue_size';
|
||||||
|
|
||||||
@@ -85,6 +91,8 @@ export interface CatalogProvider {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
providerType: string;
|
providerType: string;
|
||||||
iconPath?: string;
|
iconPath?: string;
|
||||||
|
defaultBaseUrl?: string;
|
||||||
|
defaultAuthType?: string;
|
||||||
source?: 'gateway' | 'server-main' | 'sync' | 'server-main.integration-platform' | string;
|
source?: 'gateway' | 'server-main' | 'sync' | 'server-main.integration-platform' | string;
|
||||||
capabilitySchema?: Record<string, unknown>;
|
capabilitySchema?: Record<string, unknown>;
|
||||||
defaultRateLimitPolicy?: RateLimitPolicy;
|
defaultRateLimitPolicy?: RateLimitPolicy;
|
||||||
@@ -100,6 +108,8 @@ export interface CatalogProviderUpsertRequest {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
providerType?: string;
|
providerType?: string;
|
||||||
iconPath?: string;
|
iconPath?: string;
|
||||||
|
defaultBaseUrl?: string;
|
||||||
|
defaultAuthType?: string;
|
||||||
source?: 'gateway' | 'server-main' | 'sync' | 'server-main.integration-platform' | string;
|
source?: 'gateway' | 'server-main' | 'sync' | 'server-main.integration-platform' | string;
|
||||||
capabilitySchema?: Record<string, unknown>;
|
capabilitySchema?: Record<string, unknown>;
|
||||||
defaultRateLimitPolicy?: RateLimitPolicy;
|
defaultRateLimitPolicy?: RateLimitPolicy;
|
||||||
@@ -112,12 +122,19 @@ export interface BaseModelCatalogItem {
|
|||||||
providerKey: string;
|
providerKey: string;
|
||||||
canonicalModelKey: string;
|
canonicalModelKey: string;
|
||||||
providerModelName: string;
|
providerModelName: string;
|
||||||
modelType: 'chat' | 'image' | 'video' | 'audio' | 'embedding' | 'music' | 'digital_human' | 'model_3d' | string;
|
modelType: string[];
|
||||||
displayName: string;
|
modelAlias: string;
|
||||||
|
displayName?: string;
|
||||||
capabilities?: Record<string, unknown>;
|
capabilities?: Record<string, unknown>;
|
||||||
baseBillingConfig?: BillingConfig;
|
baseBillingConfig?: BillingConfig;
|
||||||
defaultRateLimitPolicy?: RateLimitPolicy;
|
defaultRateLimitPolicy?: RateLimitPolicy;
|
||||||
|
pricingRuleSetId?: string;
|
||||||
|
runtimePolicySetId?: string;
|
||||||
|
runtimePolicyOverride?: RuntimePolicyOverride;
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
|
catalogType?: 'system' | 'custom' | string;
|
||||||
|
defaultSnapshot?: Record<string, unknown>;
|
||||||
|
customizedAt?: string;
|
||||||
pricingVersion: number;
|
pricingVersion: number;
|
||||||
status: 'active' | 'deprecated' | 'hidden' | string;
|
status: 'active' | 'deprecated' | 'hidden' | string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -128,12 +145,18 @@ export interface BaseModelUpsertRequest {
|
|||||||
providerKey: string;
|
providerKey: string;
|
||||||
canonicalModelKey?: string;
|
canonicalModelKey?: string;
|
||||||
providerModelName: string;
|
providerModelName: string;
|
||||||
modelType: string;
|
modelType: string[];
|
||||||
|
modelAlias?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
capabilities?: Record<string, unknown>;
|
capabilities?: Record<string, unknown>;
|
||||||
baseBillingConfig?: BillingConfig;
|
baseBillingConfig?: BillingConfig;
|
||||||
defaultRateLimitPolicy?: RateLimitPolicy;
|
defaultRateLimitPolicy?: RateLimitPolicy;
|
||||||
|
pricingRuleSetId?: string;
|
||||||
|
runtimePolicySetId?: string;
|
||||||
|
runtimePolicyOverride?: RuntimePolicyOverride;
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
|
catalogType?: 'system' | 'custom' | string;
|
||||||
|
defaultSnapshot?: Record<string, unknown>;
|
||||||
pricingVersion?: number;
|
pricingVersion?: number;
|
||||||
status?: 'active' | 'deprecated' | 'hidden' | string;
|
status?: 'active' | 'deprecated' | 'hidden' | string;
|
||||||
}
|
}
|
||||||
@@ -213,6 +236,40 @@ export interface PricingRuleSetUpsertRequest {
|
|||||||
rules: PricingRuleInput[];
|
rules: PricingRuleInput[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RuntimePolicySet {
|
||||||
|
id: string;
|
||||||
|
policyKey: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
rateLimitPolicy?: RateLimitPolicy | Record<string, unknown>;
|
||||||
|
retryPolicy?: Record<string, unknown>;
|
||||||
|
autoDisablePolicy?: Record<string, unknown>;
|
||||||
|
degradePolicy?: Record<string, unknown>;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
status: 'active' | 'disabled' | string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimePolicySetUpsertRequest {
|
||||||
|
policyKey: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
rateLimitPolicy?: RateLimitPolicy | Record<string, unknown>;
|
||||||
|
retryPolicy?: Record<string, unknown>;
|
||||||
|
autoDisablePolicy?: Record<string, unknown>;
|
||||||
|
degradePolicy?: Record<string, unknown>;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
status?: 'active' | 'disabled' | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimePolicyOverride {
|
||||||
|
rateLimitPolicy?: RateLimitPolicy | Record<string, unknown>;
|
||||||
|
retryPolicy?: Record<string, unknown>;
|
||||||
|
autoDisablePolicy?: Record<string, unknown>;
|
||||||
|
degradePolicy?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GatewayUser {
|
export interface GatewayUser {
|
||||||
id: string;
|
id: string;
|
||||||
userKey: string;
|
userKey: string;
|
||||||
@@ -238,6 +295,26 @@ export interface GatewayUser {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GatewayUserUpsertRequest {
|
||||||
|
userKey?: string;
|
||||||
|
source?: 'gateway' | 'server-main' | 'sync' | string;
|
||||||
|
externalUserId?: string;
|
||||||
|
username: string;
|
||||||
|
displayName?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
password?: string;
|
||||||
|
gatewayTenantId?: string;
|
||||||
|
tenantId?: string;
|
||||||
|
tenantKey?: string;
|
||||||
|
defaultUserGroupId?: string;
|
||||||
|
roles?: string[];
|
||||||
|
authProfile?: Record<string, unknown>;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
status?: 'active' | 'disabled' | 'locked' | 'deleted' | string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GatewayTenant {
|
export interface GatewayTenant {
|
||||||
id: string;
|
id: string;
|
||||||
tenantKey: string;
|
tenantKey: string;
|
||||||
@@ -258,6 +335,21 @@ export interface GatewayTenant {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GatewayTenantUpsertRequest {
|
||||||
|
tenantKey: string;
|
||||||
|
source?: 'gateway' | 'server-main' | 'sync' | string;
|
||||||
|
externalTenantId?: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
defaultUserGroupId?: string;
|
||||||
|
planKey?: string;
|
||||||
|
billingProfile?: Record<string, unknown>;
|
||||||
|
rateLimitPolicy?: RateLimitPolicy;
|
||||||
|
authPolicy?: Record<string, unknown>;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
status?: 'active' | 'disabled' | 'locked' | 'deleted' | string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserGroup {
|
export interface UserGroup {
|
||||||
id: string;
|
id: string;
|
||||||
groupKey: string;
|
groupKey: string;
|
||||||
@@ -269,11 +361,26 @@ export interface UserGroup {
|
|||||||
billingDiscountPolicy?: Record<string, unknown>;
|
billingDiscountPolicy?: Record<string, unknown>;
|
||||||
rateLimitPolicy?: RateLimitPolicy;
|
rateLimitPolicy?: RateLimitPolicy;
|
||||||
quotaPolicy?: Record<string, unknown>;
|
quotaPolicy?: Record<string, unknown>;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
status: 'active' | 'disabled' | string;
|
status: 'active' | 'disabled' | string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserGroupUpsertRequest {
|
||||||
|
groupKey: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
source?: 'gateway' | 'server-main' | 'sync' | string;
|
||||||
|
priority?: number;
|
||||||
|
rechargeDiscountPolicy?: Record<string, unknown>;
|
||||||
|
billingDiscountPolicy?: Record<string, unknown>;
|
||||||
|
rateLimitPolicy?: RateLimitPolicy;
|
||||||
|
quotaPolicy?: Record<string, unknown>;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
status?: 'active' | 'disabled' | string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserGroupMembership {
|
export interface UserGroupMembership {
|
||||||
id: string;
|
id: string;
|
||||||
groupId: string;
|
groupId: string;
|
||||||
@@ -288,6 +395,57 @@ export interface UserGroupMembership {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type GatewayAccessSubjectType = 'user_group' | 'tenant' | 'user' | 'api_key';
|
||||||
|
export type GatewayAccessResourceType = 'platform' | 'platform_model' | 'base_model';
|
||||||
|
export type GatewayAccessEffect = 'allow' | 'deny';
|
||||||
|
|
||||||
|
export interface GatewayAccessRule {
|
||||||
|
id: string;
|
||||||
|
subjectType: GatewayAccessSubjectType | string;
|
||||||
|
subjectId: string;
|
||||||
|
resourceType: GatewayAccessResourceType | string;
|
||||||
|
resourceId: string;
|
||||||
|
effect: GatewayAccessEffect | string;
|
||||||
|
priority: number;
|
||||||
|
minPermissionLevel: number;
|
||||||
|
conditions?: Record<string, unknown>;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
status: 'active' | 'disabled' | string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GatewayAccessRuleUpsertRequest {
|
||||||
|
subjectType: GatewayAccessSubjectType | string;
|
||||||
|
subjectId: string;
|
||||||
|
resourceType: GatewayAccessResourceType | string;
|
||||||
|
resourceId: string;
|
||||||
|
effect: GatewayAccessEffect | string;
|
||||||
|
priority?: number;
|
||||||
|
minPermissionLevel?: number;
|
||||||
|
conditions?: Record<string, unknown>;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
status?: 'active' | 'disabled' | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GatewayAccessRuleResourceRequest {
|
||||||
|
resourceType: GatewayAccessResourceType | string;
|
||||||
|
resourceId: string;
|
||||||
|
priority?: number;
|
||||||
|
minPermissionLevel?: number;
|
||||||
|
conditions?: Record<string, unknown>;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
status?: 'active' | 'disabled' | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GatewayAccessRuleBatchRequest {
|
||||||
|
subjectType: GatewayAccessSubjectType | string;
|
||||||
|
subjectId: string;
|
||||||
|
effect: GatewayAccessEffect | string;
|
||||||
|
upsertResources?: GatewayAccessRuleResourceRequest[];
|
||||||
|
deleteResources?: GatewayAccessRuleResourceRequest[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface GatewayApiKey {
|
export interface GatewayApiKey {
|
||||||
id: string;
|
id: string;
|
||||||
gatewayTenantId?: string;
|
gatewayTenantId?: string;
|
||||||
@@ -313,6 +471,10 @@ export interface CreatedGatewayApiKey {
|
|||||||
secret: string;
|
secret: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PlayableGatewayApiKey extends GatewayApiKey {
|
||||||
|
secret: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GatewayWalletAccount {
|
export interface GatewayWalletAccount {
|
||||||
id: string;
|
id: string;
|
||||||
gatewayTenantId?: string;
|
gatewayTenantId?: string;
|
||||||
@@ -406,6 +568,11 @@ export interface PlatformModel {
|
|||||||
pricingRuleSetId?: string;
|
pricingRuleSetId?: string;
|
||||||
billingConfigOverride?: BillingConfig;
|
billingConfigOverride?: BillingConfig;
|
||||||
billingConfig?: BillingConfig;
|
billingConfig?: BillingConfig;
|
||||||
|
permissionConfig?: Record<string, unknown>;
|
||||||
|
retryPolicy?: Record<string, unknown>;
|
||||||
|
rateLimitPolicy?: RateLimitPolicy;
|
||||||
|
runtimePolicySetId?: string;
|
||||||
|
runtimePolicyOverride?: RuntimePolicyOverride;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -433,14 +600,29 @@ export interface GatewayTask {
|
|||||||
gatewayTenantId?: string;
|
gatewayTenantId?: string;
|
||||||
tenantId?: string;
|
tenantId?: string;
|
||||||
tenantKey?: string;
|
tenantKey?: string;
|
||||||
|
apiKeyId?: string;
|
||||||
|
apiKeyName?: string;
|
||||||
|
apiKeyPrefix?: string;
|
||||||
userGroupId?: string;
|
userGroupId?: string;
|
||||||
userGroupKey?: string;
|
userGroupKey?: string;
|
||||||
userGroupPolicySnapshot?: Record<string, unknown>;
|
userGroupPolicySnapshot?: Record<string, unknown>;
|
||||||
model: string;
|
model: string;
|
||||||
|
modelType?: string;
|
||||||
|
requestedModel?: string;
|
||||||
|
resolvedModel?: string;
|
||||||
|
requestId?: string;
|
||||||
request?: Record<string, unknown>;
|
request?: Record<string, unknown>;
|
||||||
status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled' | string;
|
status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled' | string;
|
||||||
result?: Record<string, unknown>;
|
result?: Record<string, unknown>;
|
||||||
billings?: unknown[];
|
billings?: unknown[];
|
||||||
|
usage?: Record<string, unknown>;
|
||||||
|
metrics?: Record<string, unknown>;
|
||||||
|
billingSummary?: Record<string, unknown>;
|
||||||
|
finalChargeAmount?: number;
|
||||||
|
responseStartedAt?: string;
|
||||||
|
responseFinishedAt?: string;
|
||||||
|
responseDurationMs?: number;
|
||||||
|
finishedAt?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user