Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
18
backend/sdk/translator/builtin/builtin.go
Normal file
18
backend/sdk/translator/builtin/builtin.go
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// Package builtin exposes the built-in translator registrations for SDK users.
|
||||
package builtin
|
||||
|
||||
import (
|
||||
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
|
||||
|
||||
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator"
|
||||
)
|
||||
|
||||
// Registry exposes the default registry populated with all built-in translators.
|
||||
func Registry() *sdktranslator.Registry {
|
||||
return sdktranslator.Default()
|
||||
}
|
||||
|
||||
// Pipeline returns a pipeline that already contains the built-in translators.
|
||||
func Pipeline() *sdktranslator.Pipeline {
|
||||
return sdktranslator.NewPipeline(sdktranslator.Default())
|
||||
}
|
||||
14
backend/sdk/translator/format.go
Normal file
14
backend/sdk/translator/format.go
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
package translator
|
||||
|
||||
// Format identifies a request/response schema used inside the proxy.
|
||||
type Format string
|
||||
|
||||
// FromString converts an arbitrary identifier to a translator format.
|
||||
func FromString(v string) Format {
|
||||
return Format(v)
|
||||
}
|
||||
|
||||
// String returns the raw schema identifier.
|
||||
func (f Format) String() string {
|
||||
return string(f)
|
||||
}
|
||||
12
backend/sdk/translator/formats.go
Normal file
12
backend/sdk/translator/formats.go
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
package translator
|
||||
|
||||
// Common format identifiers exposed for SDK users.
|
||||
const (
|
||||
FormatOpenAI Format = "openai"
|
||||
FormatOpenAIResponse Format = "openai-response"
|
||||
FormatClaude Format = "claude"
|
||||
FormatGemini Format = "gemini"
|
||||
FormatCodex Format = "codex"
|
||||
FormatAntigravity Format = "antigravity"
|
||||
FormatInteractions Format = "interactions"
|
||||
)
|
||||
43
backend/sdk/translator/helpers.go
Normal file
43
backend/sdk/translator/helpers.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package translator
|
||||
|
||||
import "context"
|
||||
|
||||
// TranslateRequestByFormatName converts a request payload between schemas by their string identifiers.
|
||||
func TranslateRequestByFormatName(from, to Format, model string, rawJSON []byte, stream bool) []byte {
|
||||
return TranslateRequest(from, to, model, rawJSON, stream)
|
||||
}
|
||||
|
||||
// HasRequestTransformerByFormatName reports whether a request translator exists between two schemas.
|
||||
func HasRequestTransformerByFormatName(from, to Format) bool {
|
||||
return HasRequestTransformer(from, to)
|
||||
}
|
||||
|
||||
// HasResponseTransformerByFormatName reports whether a response translator exists between two schemas.
|
||||
func HasResponseTransformerByFormatName(from, to Format) bool {
|
||||
return HasResponseTransformer(from, to)
|
||||
}
|
||||
|
||||
// HasStreamResponseTransformerByFormatName reports whether a stream response translator exists between two schemas.
|
||||
func HasStreamResponseTransformerByFormatName(from, to Format) bool {
|
||||
return HasStreamResponseTransformer(from, to)
|
||||
}
|
||||
|
||||
// HasNonStreamResponseTransformerByFormatName reports whether a non-stream response translator exists between two schemas.
|
||||
func HasNonStreamResponseTransformerByFormatName(from, to Format) bool {
|
||||
return HasNonStreamResponseTransformer(from, to)
|
||||
}
|
||||
|
||||
// TranslateStreamByFormatName converts streaming responses between schemas by their string identifiers.
|
||||
func TranslateStreamByFormatName(ctx context.Context, from, to Format, model string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
|
||||
return TranslateStream(ctx, from, to, model, originalRequestRawJSON, requestRawJSON, rawJSON, param)
|
||||
}
|
||||
|
||||
// TranslateNonStreamByFormatName converts non-streaming responses between schemas by their string identifiers.
|
||||
func TranslateNonStreamByFormatName(ctx context.Context, from, to Format, model string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []byte {
|
||||
return TranslateNonStream(ctx, from, to, model, originalRequestRawJSON, requestRawJSON, rawJSON, param)
|
||||
}
|
||||
|
||||
// TranslateTokenCountByFormatName converts token counts between schemas by their string identifiers.
|
||||
func TranslateTokenCountByFormatName(ctx context.Context, from, to Format, count int64, rawJSON []byte) []byte {
|
||||
return TranslateTokenCount(ctx, from, to, count, rawJSON)
|
||||
}
|
||||
106
backend/sdk/translator/pipeline.go
Normal file
106
backend/sdk/translator/pipeline.go
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
package translator
|
||||
|
||||
import "context"
|
||||
|
||||
// RequestEnvelope represents a request in the translation pipeline.
|
||||
type RequestEnvelope struct {
|
||||
Format Format
|
||||
Model string
|
||||
Stream bool
|
||||
Body []byte
|
||||
}
|
||||
|
||||
// ResponseEnvelope represents a response in the translation pipeline.
|
||||
type ResponseEnvelope struct {
|
||||
Format Format
|
||||
Model string
|
||||
Stream bool
|
||||
Body []byte
|
||||
Chunks [][]byte
|
||||
}
|
||||
|
||||
// RequestMiddleware decorates request translation.
|
||||
type RequestMiddleware func(ctx context.Context, req RequestEnvelope, next RequestHandler) (RequestEnvelope, error)
|
||||
|
||||
// ResponseMiddleware decorates response translation.
|
||||
type ResponseMiddleware func(ctx context.Context, resp ResponseEnvelope, next ResponseHandler) (ResponseEnvelope, error)
|
||||
|
||||
// RequestHandler performs request translation between formats.
|
||||
type RequestHandler func(ctx context.Context, req RequestEnvelope) (RequestEnvelope, error)
|
||||
|
||||
// ResponseHandler performs response translation between formats.
|
||||
type ResponseHandler func(ctx context.Context, resp ResponseEnvelope) (ResponseEnvelope, error)
|
||||
|
||||
// Pipeline orchestrates request/response transformation with middleware support.
|
||||
type Pipeline struct {
|
||||
registry *Registry
|
||||
requestMiddleware []RequestMiddleware
|
||||
responseMiddleware []ResponseMiddleware
|
||||
}
|
||||
|
||||
// NewPipeline constructs a pipeline bound to the provided registry.
|
||||
func NewPipeline(registry *Registry) *Pipeline {
|
||||
if registry == nil {
|
||||
registry = Default()
|
||||
}
|
||||
return &Pipeline{registry: registry}
|
||||
}
|
||||
|
||||
// UseRequest adds request middleware executed in registration order.
|
||||
func (p *Pipeline) UseRequest(mw RequestMiddleware) {
|
||||
if mw != nil {
|
||||
p.requestMiddleware = append(p.requestMiddleware, mw)
|
||||
}
|
||||
}
|
||||
|
||||
// UseResponse adds response middleware executed in registration order.
|
||||
func (p *Pipeline) UseResponse(mw ResponseMiddleware) {
|
||||
if mw != nil {
|
||||
p.responseMiddleware = append(p.responseMiddleware, mw)
|
||||
}
|
||||
}
|
||||
|
||||
// TranslateRequest applies middleware and registry transformations.
|
||||
func (p *Pipeline) TranslateRequest(ctx context.Context, from, to Format, req RequestEnvelope) (RequestEnvelope, error) {
|
||||
terminal := func(ctx context.Context, input RequestEnvelope) (RequestEnvelope, error) {
|
||||
translated := p.registry.TranslateRequest(from, to, input.Model, input.Body, input.Stream)
|
||||
input.Body = translated
|
||||
input.Format = to
|
||||
return input, nil
|
||||
}
|
||||
|
||||
handler := terminal
|
||||
for i := len(p.requestMiddleware) - 1; i >= 0; i-- {
|
||||
mw := p.requestMiddleware[i]
|
||||
next := handler
|
||||
handler = func(ctx context.Context, r RequestEnvelope) (RequestEnvelope, error) {
|
||||
return mw(ctx, r, next)
|
||||
}
|
||||
}
|
||||
|
||||
return handler(ctx, req)
|
||||
}
|
||||
|
||||
// TranslateResponse applies middleware and registry transformations.
|
||||
func (p *Pipeline) TranslateResponse(ctx context.Context, from, to Format, resp ResponseEnvelope, originalReq, translatedReq []byte, param *any) (ResponseEnvelope, error) {
|
||||
terminal := func(ctx context.Context, input ResponseEnvelope) (ResponseEnvelope, error) {
|
||||
if input.Stream {
|
||||
input.Chunks = p.registry.TranslateStream(ctx, from, to, input.Model, originalReq, translatedReq, input.Body, param)
|
||||
} else {
|
||||
input.Body = p.registry.TranslateNonStream(ctx, from, to, input.Model, originalReq, translatedReq, input.Body, param)
|
||||
}
|
||||
input.Format = to
|
||||
return input, nil
|
||||
}
|
||||
|
||||
handler := terminal
|
||||
for i := len(p.responseMiddleware) - 1; i >= 0; i-- {
|
||||
mw := p.responseMiddleware[i]
|
||||
next := handler
|
||||
handler = func(ctx context.Context, r ResponseEnvelope) (ResponseEnvelope, error) {
|
||||
return mw(ctx, r, next)
|
||||
}
|
||||
}
|
||||
|
||||
return handler(ctx, resp)
|
||||
}
|
||||
12
backend/sdk/translator/plugin_hooks.go
Normal file
12
backend/sdk/translator/plugin_hooks.go
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
package translator
|
||||
|
||||
import "context"
|
||||
|
||||
// PluginHooks defines optional translator extension hooks provided by plugins.
|
||||
type PluginHooks interface {
|
||||
NormalizeRequest(ctx context.Context, from, to Format, model string, body []byte, stream bool) []byte
|
||||
TranslateRequest(ctx context.Context, from, to Format, model string, body []byte, stream bool) ([]byte, bool)
|
||||
NormalizeResponseBefore(ctx context.Context, from, to Format, model string, originalRequestRawJSON, requestRawJSON, body []byte, stream bool) []byte
|
||||
TranslateResponse(ctx context.Context, from, to Format, model string, originalRequestRawJSON, requestRawJSON, body []byte, stream bool) ([]byte, bool)
|
||||
NormalizeResponseAfter(ctx context.Context, from, to Format, model string, originalRequestRawJSON, requestRawJSON, body []byte, stream bool) []byte
|
||||
}
|
||||
304
backend/sdk/translator/registry.go
Normal file
304
backend/sdk/translator/registry.go
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
package translator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// Registry manages translation functions across schemas.
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
requests map[Format]map[Format]RequestTransform
|
||||
responses map[Format]map[Format]ResponseTransform
|
||||
hooks PluginHooks
|
||||
}
|
||||
|
||||
// NewRegistry constructs an empty translator registry.
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{
|
||||
requests: make(map[Format]map[Format]RequestTransform),
|
||||
responses: make(map[Format]map[Format]ResponseTransform),
|
||||
}
|
||||
}
|
||||
|
||||
// Register stores request/response transforms between two formats.
|
||||
func (r *Registry) Register(from, to Format, request RequestTransform, response ResponseTransform) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, ok := r.requests[from]; !ok {
|
||||
r.requests[from] = make(map[Format]RequestTransform)
|
||||
}
|
||||
if request != nil {
|
||||
r.requests[from][to] = request
|
||||
}
|
||||
|
||||
if _, ok := r.responses[from]; !ok {
|
||||
r.responses[from] = make(map[Format]ResponseTransform)
|
||||
}
|
||||
r.responses[from][to] = response
|
||||
}
|
||||
|
||||
// SetPluginHooks stores translator plugin hooks for this registry.
|
||||
func (r *Registry) SetPluginHooks(hooks PluginHooks) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
r.hooks = hooks
|
||||
}
|
||||
|
||||
// HasPluginHooks reports whether request or response translation hooks are installed.
|
||||
func (r *Registry) HasPluginHooks() bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.hooks != nil
|
||||
}
|
||||
|
||||
// TranslateRequest converts a payload between schemas, returning the original payload
|
||||
// if no translator is registered. When falling back to the original payload, the
|
||||
// "model" field is still updated to match the resolved model name so that
|
||||
// client-side prefixes (e.g. "copilot/gpt-5-mini") are not leaked upstream.
|
||||
func (r *Registry) TranslateRequest(from, to Format, model string, rawJSON []byte, stream bool) []byte {
|
||||
r.mu.RLock()
|
||||
var fn RequestTransform
|
||||
if byTarget, ok := r.requests[from]; ok {
|
||||
fn = byTarget[to]
|
||||
}
|
||||
hooks := r.hooks
|
||||
r.mu.RUnlock()
|
||||
|
||||
body := rawJSON
|
||||
if fn != nil {
|
||||
summaryConfig := thinking.ExtractSummaryConfig(rawJSON, from.String())
|
||||
body = fn(model, body, stream)
|
||||
body = thinking.ApplySummaryConfigForModel(body, to.String(), model, summaryConfig)
|
||||
if hooks != nil {
|
||||
// Request normalizers run after native translation and own the final
|
||||
// provider payload, including any summary field they remove.
|
||||
body = hooks.NormalizeRequest(context.Background(), from, to, model, body, stream)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
if model != "" && gjson.GetBytes(body, "model").String() != model {
|
||||
if updated, err := sjson.SetBytes(body, "model", model); err != nil {
|
||||
log.Warnf("translator: failed to normalize model in request fallback: %v", err)
|
||||
} else {
|
||||
body = updated
|
||||
}
|
||||
}
|
||||
if hooks == nil {
|
||||
// No translation occurred. Preserve the documented fallback shape instead
|
||||
// of mixing target-protocol summary fields into the source payload.
|
||||
return body
|
||||
}
|
||||
|
||||
// Plugin request normalizers canonicalize the source before a plugin request
|
||||
// translator gets a chance to handle a missing native route. Extract summary
|
||||
// intent from that normalized source so a normalizer can remove or rewrite it.
|
||||
body = hooks.NormalizeRequest(context.Background(), from, to, model, body, stream)
|
||||
summaryConfig := thinking.ExtractSummaryConfig(body, from.String())
|
||||
if translated, ok := hooks.TranslateRequest(context.Background(), from, to, model, body, stream); ok {
|
||||
body = thinking.ApplySummaryConfigForModel(translated, to.String(), model, summaryConfig)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// HasRequestTransformer indicates whether a request translator exists.
|
||||
func (r *Registry) HasRequestTransformer(from, to Format) bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
if byTarget, ok := r.requests[from]; ok {
|
||||
if fn, isOk := byTarget[to]; isOk && fn != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasResponseTransformer indicates whether a response translator exists.
|
||||
func (r *Registry) HasResponseTransformer(from, to Format) bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
if byTarget, ok := r.responses[from]; ok {
|
||||
if fn, isOk := byTarget[to]; isOk && hasAnyResponseTransform(fn) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasStreamResponseTransformer indicates whether a streaming response translator exists.
|
||||
func (r *Registry) HasStreamResponseTransformer(from, to Format) bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
if byTarget, ok := r.responses[from]; ok {
|
||||
if fn, isOk := byTarget[to]; isOk && fn.Stream != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasNonStreamResponseTransformer indicates whether a non-streaming response translator exists.
|
||||
func (r *Registry) HasNonStreamResponseTransformer(from, to Format) bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
if byTarget, ok := r.responses[from]; ok {
|
||||
if fn, isOk := byTarget[to]; isOk && fn.NonStream != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TranslateStream applies the registered streaming response translator.
|
||||
func (r *Registry) TranslateStream(ctx context.Context, from, to Format, model string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
|
||||
r.mu.RLock()
|
||||
var stream ResponseStreamTransform
|
||||
if byTarget, ok := r.responses[to]; ok {
|
||||
stream = byTarget[from].Stream
|
||||
}
|
||||
hooks := r.hooks
|
||||
r.mu.RUnlock()
|
||||
|
||||
body := rawJSON
|
||||
if hooks != nil {
|
||||
body = hooks.NormalizeResponseBefore(ctx, from, to, model, originalRequestRawJSON, requestRawJSON, body, true)
|
||||
}
|
||||
|
||||
var outputs [][]byte
|
||||
usedNativeTransform := false
|
||||
if stream != nil {
|
||||
usedNativeTransform = true
|
||||
outputs = stream(ctx, model, originalRequestRawJSON, requestRawJSON, body, param)
|
||||
} else if hooks != nil {
|
||||
if translated, ok := hooks.TranslateResponse(ctx, from, to, model, originalRequestRawJSON, requestRawJSON, body, true); ok {
|
||||
outputs = [][]byte{translated}
|
||||
}
|
||||
}
|
||||
if outputs == nil && !usedNativeTransform {
|
||||
outputs = [][]byte{body}
|
||||
}
|
||||
if hooks != nil {
|
||||
for i, output := range outputs {
|
||||
outputs[i] = hooks.NormalizeResponseAfter(ctx, from, to, model, originalRequestRawJSON, requestRawJSON, output, true)
|
||||
}
|
||||
}
|
||||
return outputs
|
||||
}
|
||||
|
||||
// TranslateNonStream applies the registered non-stream response translator.
|
||||
func (r *Registry) TranslateNonStream(ctx context.Context, from, to Format, model string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []byte {
|
||||
r.mu.RLock()
|
||||
var fn ResponseTransform
|
||||
if byTarget, ok := r.responses[to]; ok {
|
||||
fn = byTarget[from]
|
||||
}
|
||||
hooks := r.hooks
|
||||
r.mu.RUnlock()
|
||||
|
||||
body := rawJSON
|
||||
if hooks != nil {
|
||||
body = hooks.NormalizeResponseBefore(ctx, from, to, model, originalRequestRawJSON, requestRawJSON, body, false)
|
||||
}
|
||||
if fn.NonStream != nil {
|
||||
body = fn.NonStream(ctx, model, originalRequestRawJSON, requestRawJSON, body, param)
|
||||
} else if hooks != nil {
|
||||
if translated, ok := hooks.TranslateResponse(ctx, from, to, model, originalRequestRawJSON, requestRawJSON, body, false); ok {
|
||||
body = translated
|
||||
}
|
||||
}
|
||||
if hooks != nil {
|
||||
body = hooks.NormalizeResponseAfter(ctx, from, to, model, originalRequestRawJSON, requestRawJSON, body, false)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// TranslateTokenCount applies the registered token count response translator.
|
||||
func (r *Registry) TranslateTokenCount(ctx context.Context, from, to Format, count int64, rawJSON []byte) []byte {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
if byTarget, ok := r.responses[to]; ok {
|
||||
if fn, isOk := byTarget[from]; isOk && fn.TokenCount != nil {
|
||||
return fn.TokenCount(ctx, count)
|
||||
}
|
||||
}
|
||||
return rawJSON
|
||||
}
|
||||
|
||||
var defaultRegistry = NewRegistry()
|
||||
|
||||
// Default exposes the package-level registry for shared use.
|
||||
func Default() *Registry {
|
||||
return defaultRegistry
|
||||
}
|
||||
|
||||
// Register attaches transforms to the default registry.
|
||||
func Register(from, to Format, request RequestTransform, response ResponseTransform) {
|
||||
defaultRegistry.Register(from, to, request, response)
|
||||
}
|
||||
|
||||
// SetPluginHooks stores plugin hooks on the default registry.
|
||||
func SetPluginHooks(hooks PluginHooks) {
|
||||
defaultRegistry.SetPluginHooks(hooks)
|
||||
}
|
||||
|
||||
// HasPluginHooks reports whether hooks are installed on the default registry.
|
||||
func HasPluginHooks() bool {
|
||||
return defaultRegistry.HasPluginHooks()
|
||||
}
|
||||
|
||||
// TranslateRequest is a helper on the default registry.
|
||||
func TranslateRequest(from, to Format, model string, rawJSON []byte, stream bool) []byte {
|
||||
return defaultRegistry.TranslateRequest(from, to, model, rawJSON, stream)
|
||||
}
|
||||
|
||||
// HasRequestTransformer inspects the default registry.
|
||||
func HasRequestTransformer(from, to Format) bool {
|
||||
return defaultRegistry.HasRequestTransformer(from, to)
|
||||
}
|
||||
|
||||
// HasResponseTransformer inspects the default registry.
|
||||
func HasResponseTransformer(from, to Format) bool {
|
||||
return defaultRegistry.HasResponseTransformer(from, to)
|
||||
}
|
||||
|
||||
// HasStreamResponseTransformer inspects the default registry for a streaming response translator.
|
||||
func HasStreamResponseTransformer(from, to Format) bool {
|
||||
return defaultRegistry.HasStreamResponseTransformer(from, to)
|
||||
}
|
||||
|
||||
// HasNonStreamResponseTransformer inspects the default registry for a non-streaming response translator.
|
||||
func HasNonStreamResponseTransformer(from, to Format) bool {
|
||||
return defaultRegistry.HasNonStreamResponseTransformer(from, to)
|
||||
}
|
||||
|
||||
// TranslateStream is a helper on the default registry.
|
||||
func TranslateStream(ctx context.Context, from, to Format, model string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
|
||||
return defaultRegistry.TranslateStream(ctx, from, to, model, originalRequestRawJSON, requestRawJSON, rawJSON, param)
|
||||
}
|
||||
|
||||
// TranslateNonStream is a helper on the default registry.
|
||||
func TranslateNonStream(ctx context.Context, from, to Format, model string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []byte {
|
||||
return defaultRegistry.TranslateNonStream(ctx, from, to, model, originalRequestRawJSON, requestRawJSON, rawJSON, param)
|
||||
}
|
||||
|
||||
// TranslateTokenCount is a helper on the default registry.
|
||||
func TranslateTokenCount(ctx context.Context, from, to Format, count int64, rawJSON []byte) []byte {
|
||||
return defaultRegistry.TranslateTokenCount(ctx, from, to, count, rawJSON)
|
||||
}
|
||||
|
||||
func hasAnyResponseTransform(fn ResponseTransform) bool {
|
||||
return fn.Stream != nil || fn.NonStream != nil || fn.TokenCount != nil
|
||||
}
|
||||
52
backend/sdk/translator/registry_bytes_test.go
Normal file
52
backend/sdk/translator/registry_bytes_test.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
package translator
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRegistryTranslateStreamReturnsByteChunks(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
registry.Register(FormatOpenAI, FormatGemini, nil, ResponseTransform{
|
||||
Stream: func(ctx context.Context, model string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
|
||||
return [][]byte{append([]byte(nil), rawJSON...)}
|
||||
},
|
||||
})
|
||||
|
||||
got := registry.TranslateStream(context.Background(), FormatGemini, FormatOpenAI, "model", nil, nil, []byte(`{"chunk":true}`), nil)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 chunk, got %d", len(got))
|
||||
}
|
||||
if !bytes.Equal(got[0], []byte(`{"chunk":true}`)) {
|
||||
t.Fatalf("unexpected chunk: %s", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryTranslateNonStreamReturnsBytes(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
registry.Register(FormatOpenAI, FormatGemini, nil, ResponseTransform{
|
||||
NonStream: func(ctx context.Context, model string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []byte {
|
||||
return append([]byte(nil), rawJSON...)
|
||||
},
|
||||
})
|
||||
|
||||
got := registry.TranslateNonStream(context.Background(), FormatGemini, FormatOpenAI, "model", nil, nil, []byte(`{"done":true}`), nil)
|
||||
if !bytes.Equal(got, []byte(`{"done":true}`)) {
|
||||
t.Fatalf("unexpected payload: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryTranslateTokenCountReturnsBytes(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
registry.Register(FormatOpenAI, FormatGemini, nil, ResponseTransform{
|
||||
TokenCount: func(ctx context.Context, count int64) []byte {
|
||||
return []byte(`{"totalTokens":7}`)
|
||||
},
|
||||
})
|
||||
|
||||
got := registry.TranslateTokenCount(context.Background(), FormatGemini, FormatOpenAI, 7, []byte(`{"fallback":true}`))
|
||||
if !bytes.Equal(got, []byte(`{"totalTokens":7}`)) {
|
||||
t.Fatalf("unexpected payload: %s", got)
|
||||
}
|
||||
}
|
||||
258
backend/sdk/translator/registry_summary_test.go
Normal file
258
backend/sdk/translator/registry_summary_test.go
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
package translator
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
func TestRegistryTranslateRequestAppliesSummaryIntent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
from Format
|
||||
to Format
|
||||
input string
|
||||
translated string
|
||||
path string
|
||||
want string
|
||||
wantExists bool
|
||||
}{
|
||||
{
|
||||
name: "chat effort enables Claude summary",
|
||||
from: FormatOpenAI,
|
||||
to: FormatClaude,
|
||||
input: `{"reasoning_effort":"high"}`,
|
||||
translated: `{"thinking":{"type":"adaptive"}}`,
|
||||
path: "thinking.display",
|
||||
want: "summarized",
|
||||
wantExists: true,
|
||||
},
|
||||
{
|
||||
name: "responses effort alone leaves Claude display absent",
|
||||
from: FormatOpenAIResponse,
|
||||
to: FormatClaude,
|
||||
input: `{"reasoning":{"effort":"high"}}`,
|
||||
translated: `{"thinking":{"type":"adaptive"}}`,
|
||||
path: "thinking.display",
|
||||
},
|
||||
{
|
||||
name: "responses summary enables Claude summary",
|
||||
from: FormatOpenAIResponse,
|
||||
to: FormatClaude,
|
||||
input: `{"reasoning":{"effort":"high","summary":"auto"}}`,
|
||||
translated: `{"thinking":{"type":"adaptive"}}`,
|
||||
path: "thinking.display",
|
||||
want: "summarized",
|
||||
wantExists: true,
|
||||
},
|
||||
{
|
||||
name: "responses null summary disables Gemini summaries",
|
||||
from: FormatOpenAIResponse,
|
||||
to: FormatGemini,
|
||||
input: `{"reasoning":{"effort":"high","summary":null}}`,
|
||||
translated: `{"generationConfig":{"thinkingConfig":{"thinkingLevel":"high"}}}`,
|
||||
path: "generationConfig.thinkingConfig.includeThoughts",
|
||||
want: "false",
|
||||
wantExists: true,
|
||||
},
|
||||
{
|
||||
name: "Google Chat extension overrides effort",
|
||||
from: FormatOpenAI,
|
||||
to: FormatGemini,
|
||||
input: `{"reasoning_effort":"high","extra_body":{"google":{"thinking_config":{"include_thoughts":false}}}}`,
|
||||
translated: `{"generationConfig":{"thinkingConfig":{"thinkingLevel":"high","includeThoughts":true}}}`,
|
||||
path: "generationConfig.thinkingConfig.includeThoughts",
|
||||
want: "false",
|
||||
wantExists: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
registry.Register(test.from, test.to, func(_ string, _ []byte, _ bool) []byte {
|
||||
return []byte(test.translated)
|
||||
}, ResponseTransform{})
|
||||
out := registry.TranslateRequest(test.from, test.to, "model", []byte(test.input), false)
|
||||
result := gjson.GetBytes(out, test.path)
|
||||
if result.Exists() != test.wantExists {
|
||||
t.Fatalf("%s exists = %v, want %v; body=%s", test.path, result.Exists(), test.wantExists, out)
|
||||
}
|
||||
if test.wantExists && result.String() != test.want {
|
||||
t.Fatalf("%s = %q, want %q; body=%s", test.path, result.String(), test.want, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryTranslateRequestActivatesClaudeForEnabledSummary(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
registry.Register(FormatOpenAIResponse, FormatClaude, func(_ string, _ []byte, _ bool) []byte {
|
||||
return []byte(`{"model":"claude-opus-5","max_tokens":32000}`)
|
||||
}, ResponseTransform{})
|
||||
out := registry.TranslateRequest(
|
||||
FormatOpenAIResponse,
|
||||
FormatClaude,
|
||||
"claude-opus-5",
|
||||
[]byte(`{"reasoning":{"summary":"auto"},"input":"hi"}`),
|
||||
false,
|
||||
)
|
||||
if got := gjson.GetBytes(out, "thinking.type").String(); got != "adaptive" {
|
||||
t.Fatalf("thinking.type = %q, want adaptive; body=%s", got, out)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "thinking.display").String(); got != "summarized" {
|
||||
t.Fatalf("thinking.display = %q, want summarized; body=%s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryTranslateRequestDoesNotActivateClaudeForDisabledSummary(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
registry.Register(FormatOpenAIResponse, FormatClaude, func(_ string, _ []byte, _ bool) []byte {
|
||||
return []byte(`{"model":"claude-opus-5","max_tokens":32000}`)
|
||||
}, ResponseTransform{})
|
||||
out := registry.TranslateRequest(
|
||||
FormatOpenAIResponse,
|
||||
FormatClaude,
|
||||
"claude-opus-5",
|
||||
[]byte(`{"reasoning":{"summary":null},"input":"hi"}`),
|
||||
false,
|
||||
)
|
||||
if gjson.GetBytes(out, "thinking").Exists() {
|
||||
t.Fatalf("disabled summary activated Claude thinking: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryTranslateRequestPreservesNativeClaudeMissingDisplay(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
body := []byte(`{"model":"claude-opus-5","thinking":{"type":"adaptive"}}`)
|
||||
out := registry.TranslateRequest(FormatClaude, FormatClaude, "claude-opus-5", body, true)
|
||||
if gjson.GetBytes(out, "thinking.display").Exists() {
|
||||
t.Fatalf("native Claude request without display gained one: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryTranslateRequestDoesNotMixSummaryIntoFallback(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
body := []byte(`{"model":"gemini-3.6-flash","reasoning":{"summary":"auto"},"input":"hi"}`)
|
||||
out := registry.TranslateRequest(FormatOpenAIResponse, FormatGemini, "gemini-3.6-flash", body, false)
|
||||
if !bytes.Equal(out, body) {
|
||||
t.Fatalf("missing translator changed fallback body: got %s, want %s", out, body)
|
||||
}
|
||||
if gjson.GetBytes(out, "generationConfig").Exists() {
|
||||
t.Fatalf("missing translator mixed Gemini fields into Responses body: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryTranslateRequestPluginMissDoesNotMixSummary(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
hooks := &fakePluginHooks{requestTranslateOK: false}
|
||||
registry.SetPluginHooks(hooks)
|
||||
body := []byte(`{"model":"gemini-3.6-flash","reasoning":{"summary":"auto"},"input":"hi"}`)
|
||||
out := registry.TranslateRequest(FormatOpenAIResponse, FormatGemini, "gemini-3.6-flash", body, false)
|
||||
if !bytes.Equal(out, body) {
|
||||
t.Fatalf("plugin translation miss changed fallback body: got %s, want %s", out, body)
|
||||
}
|
||||
if gjson.GetBytes(out, "generationConfig").Exists() {
|
||||
t.Fatalf("plugin translation miss mixed Gemini fields into Responses body: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryTranslateRequestAppliesSummaryAfterPluginTranslation(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
hooks := &fakePluginHooks{
|
||||
requestTranslateBody: []byte(`{"generationConfig":{"thinkingConfig":{"thinkingLevel":"high"}}}`),
|
||||
requestTranslateOK: true,
|
||||
}
|
||||
registry.SetPluginHooks(hooks)
|
||||
out := registry.TranslateRequest(
|
||||
FormatOpenAIResponse,
|
||||
FormatGemini,
|
||||
"gemini-3.6-flash",
|
||||
[]byte(`{"reasoning":{"summary":"auto"},"input":"hi"}`),
|
||||
false,
|
||||
)
|
||||
if !gjson.GetBytes(out, "generationConfig.thinkingConfig.includeThoughts").Bool() {
|
||||
t.Fatalf("plugin-translated request lost canonical summary: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryTranslateRequestPluginNormalizerOwnsSourceSummaryIntent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
normalize func([]byte) []byte
|
||||
wantExists bool
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "removed summary remains absent",
|
||||
normalize: func(body []byte) []byte {
|
||||
out, _ := sjson.DeleteBytes(body, "reasoning.summary")
|
||||
return out
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "disabled summary replaces enabled intent",
|
||||
normalize: func(body []byte) []byte {
|
||||
out, _ := sjson.SetBytes(body, "reasoning.summary", nil)
|
||||
return out
|
||||
},
|
||||
wantExists: true,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
hooks := &fakePluginHooks{
|
||||
normalizeRequest: test.normalize,
|
||||
requestTranslateBody: []byte(`{"generationConfig":{"thinkingConfig":{"thinkingLevel":"high"}}}`),
|
||||
requestTranslateOK: true,
|
||||
}
|
||||
registry.SetPluginHooks(hooks)
|
||||
|
||||
out := registry.TranslateRequest(
|
||||
FormatOpenAIResponse,
|
||||
FormatGemini,
|
||||
"gemini-3.6-flash",
|
||||
[]byte(`{"reasoning":{"summary":"auto"},"input":"hi"}`),
|
||||
false,
|
||||
)
|
||||
result := gjson.GetBytes(out, "generationConfig.thinkingConfig.includeThoughts")
|
||||
if result.Exists() != test.wantExists {
|
||||
t.Fatalf("includeThoughts exists = %v, want %v; body=%s", result.Exists(), test.wantExists, out)
|
||||
}
|
||||
if test.wantExists && result.Bool() != test.want {
|
||||
t.Fatalf("includeThoughts = %v, want %v; body=%s", result.Bool(), test.want, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryTranslateRequestNormalizerOwnsFinalSummaryField(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
registry.Register(FormatOpenAIResponse, FormatGemini, func(_ string, _ []byte, _ bool) []byte {
|
||||
return []byte(`{"generationConfig":{"thinkingConfig":{"thinkingLevel":"high"}}}`)
|
||||
}, ResponseTransform{})
|
||||
hooks := &fakePluginHooks{normalizeRequest: func(body []byte) []byte {
|
||||
if !gjson.GetBytes(body, "generationConfig.thinkingConfig.includeThoughts").Bool() {
|
||||
t.Fatalf("normalizer did not receive canonical enabled summary: %s", body)
|
||||
}
|
||||
out, _ := sjson.DeleteBytes(body, "generationConfig.thinkingConfig.includeThoughts")
|
||||
return out
|
||||
}}
|
||||
registry.SetPluginHooks(hooks)
|
||||
|
||||
out := registry.TranslateRequest(
|
||||
FormatOpenAIResponse,
|
||||
FormatGemini,
|
||||
"gemini-3.6-flash",
|
||||
[]byte(`{"reasoning":{"effort":"high","summary":"auto"},"input":"hi"}`),
|
||||
false,
|
||||
)
|
||||
if gjson.GetBytes(out, "generationConfig.thinkingConfig.includeThoughts").Exists() {
|
||||
t.Fatalf("summary post-processing overrode request normalizer: %s", out)
|
||||
}
|
||||
}
|
||||
419
backend/sdk/translator/registry_test.go
Normal file
419
backend/sdk/translator/registry_test.go
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
package translator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
type fakePluginHooks struct {
|
||||
calls []string
|
||||
requestTranslateBody []byte
|
||||
requestTranslateOK bool
|
||||
responseTranslateBody []byte
|
||||
responseTranslateOK bool
|
||||
normalizeRequest func([]byte) []byte
|
||||
normalizeBefore func([]byte) []byte
|
||||
normalizeAfter func([]byte) []byte
|
||||
}
|
||||
|
||||
func (h *fakePluginHooks) NormalizeRequest(ctx context.Context, from, to Format, model string, body []byte, stream bool) []byte {
|
||||
h.calls = append(h.calls, "normalize-request")
|
||||
if h.normalizeRequest != nil {
|
||||
return h.normalizeRequest(body)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (h *fakePluginHooks) TranslateRequest(ctx context.Context, from, to Format, model string, body []byte, stream bool) ([]byte, bool) {
|
||||
h.calls = append(h.calls, "translate-request")
|
||||
return h.requestTranslateBody, h.requestTranslateOK
|
||||
}
|
||||
|
||||
func (h *fakePluginHooks) NormalizeResponseBefore(ctx context.Context, from, to Format, model string, originalRequestRawJSON, requestRawJSON, body []byte, stream bool) []byte {
|
||||
h.calls = append(h.calls, "normalize-response-before")
|
||||
if h.normalizeBefore != nil {
|
||||
return h.normalizeBefore(body)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (h *fakePluginHooks) TranslateResponse(ctx context.Context, from, to Format, model string, originalRequestRawJSON, requestRawJSON, body []byte, stream bool) ([]byte, bool) {
|
||||
h.calls = append(h.calls, "translate-response")
|
||||
return h.responseTranslateBody, h.responseTranslateOK
|
||||
}
|
||||
|
||||
func (h *fakePluginHooks) NormalizeResponseAfter(ctx context.Context, from, to Format, model string, originalRequestRawJSON, requestRawJSON, body []byte, stream bool) []byte {
|
||||
h.calls = append(h.calls, "normalize-response-after")
|
||||
if h.normalizeAfter != nil {
|
||||
return h.normalizeAfter(body)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func hasCall(calls []string, want string) bool {
|
||||
for _, call := range calls {
|
||||
if call == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestHasPluginHooks(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
if registry.HasPluginHooks() {
|
||||
t.Fatal("new registry unexpectedly reports plugin hooks")
|
||||
}
|
||||
registry.SetPluginHooks(&fakePluginHooks{})
|
||||
if !registry.HasPluginHooks() {
|
||||
t.Fatal("registry did not report installed plugin hooks")
|
||||
}
|
||||
registry.SetPluginHooks(nil)
|
||||
if registry.HasPluginHooks() {
|
||||
t.Fatal("registry still reports cleared plugin hooks")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateRequest_FallbackNormalizesModel(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
model string
|
||||
payload string
|
||||
wantModel string
|
||||
wantUnchanged bool
|
||||
}{
|
||||
{
|
||||
name: "prefixed model is rewritten",
|
||||
model: "gpt-5-mini",
|
||||
payload: `{"model":"copilot/gpt-5-mini","input":"ping"}`,
|
||||
wantModel: "gpt-5-mini",
|
||||
},
|
||||
{
|
||||
name: "matching model is left unchanged",
|
||||
model: "gpt-5-mini",
|
||||
payload: `{"model":"gpt-5-mini","input":"ping"}`,
|
||||
wantModel: "gpt-5-mini",
|
||||
wantUnchanged: true,
|
||||
},
|
||||
{
|
||||
name: "empty model leaves payload unchanged",
|
||||
model: "",
|
||||
payload: `{"model":"copilot/gpt-5-mini","input":"ping"}`,
|
||||
wantModel: "copilot/gpt-5-mini",
|
||||
wantUnchanged: true,
|
||||
},
|
||||
{
|
||||
name: "deeply prefixed model is rewritten",
|
||||
model: "gpt-5.3-codex",
|
||||
payload: `{"model":"team/gpt-5.3-codex","stream":true}`,
|
||||
wantModel: "gpt-5.3-codex",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
input := []byte(tt.payload)
|
||||
got := r.TranslateRequest(Format("a"), Format("b"), tt.model, input, false)
|
||||
|
||||
gotModel := gjson.GetBytes(got, "model").String()
|
||||
if gotModel != tt.wantModel {
|
||||
t.Errorf("model = %q, want %q", gotModel, tt.wantModel)
|
||||
}
|
||||
|
||||
if tt.wantUnchanged && string(got) != tt.payload {
|
||||
t.Errorf("payload was modified when it should not have been:\ngot: %s\nwant: %s", got, tt.payload)
|
||||
}
|
||||
|
||||
// Verify other fields are preserved.
|
||||
for _, key := range []string{"input", "stream"} {
|
||||
orig := gjson.Get(tt.payload, key)
|
||||
if !orig.Exists() {
|
||||
continue
|
||||
}
|
||||
after := gjson.GetBytes(got, key)
|
||||
if orig.Raw != after.Raw {
|
||||
t.Errorf("field %q changed: got %s, want %s", key, after.Raw, orig.Raw)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateRequest_RegisteredTransformTakesPrecedence(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
from := Format("openai-response")
|
||||
to := Format("openai-response")
|
||||
|
||||
r.Register(from, to, func(model string, rawJSON []byte, stream bool) []byte {
|
||||
return []byte(`{"model":"from-transform"}`)
|
||||
}, ResponseTransform{})
|
||||
|
||||
input := []byte(`{"model":"copilot/gpt-5-mini","input":"ping"}`)
|
||||
got := r.TranslateRequest(from, to, "gpt-5-mini", input, false)
|
||||
|
||||
gotModel := gjson.GetBytes(got, "model").String()
|
||||
if gotModel != "from-transform" {
|
||||
t.Errorf("expected registered transform to take precedence, got model = %q", gotModel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasRequestTransformer(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
from := Format("from")
|
||||
to := Format("to")
|
||||
|
||||
if r.HasRequestTransformer(from, to) {
|
||||
t.Fatal("request transformer exists before registration")
|
||||
}
|
||||
|
||||
r.Register(from, to, func(model string, rawJSON []byte, stream bool) []byte {
|
||||
return rawJSON
|
||||
}, ResponseTransform{})
|
||||
|
||||
if !r.HasRequestTransformer(from, to) {
|
||||
t.Fatal("request transformer is missing after registration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasResponseTransformerIgnoresEmptyRegistration(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
from := Format("from")
|
||||
to := Format("to")
|
||||
|
||||
r.Register(from, to, func(model string, rawJSON []byte, stream bool) []byte {
|
||||
return rawJSON
|
||||
}, ResponseTransform{})
|
||||
|
||||
if r.HasResponseTransformer(from, to) {
|
||||
t.Fatal("empty response transform was reported as a response transformer")
|
||||
}
|
||||
if r.HasStreamResponseTransformer(from, to) {
|
||||
t.Fatal("empty response transform was reported as a stream response transformer")
|
||||
}
|
||||
if r.HasNonStreamResponseTransformer(from, to) {
|
||||
t.Fatal("empty response transform was reported as a non-stream response transformer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasResponseTransformerChecksConcreteResponseKinds(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r := NewRegistry()
|
||||
from := Format("from")
|
||||
streamOnlyTo := Format("stream-to")
|
||||
nonStreamOnlyTo := Format("non-stream-to")
|
||||
|
||||
r.Register(from, streamOnlyTo, nil, ResponseTransform{
|
||||
Stream: func(ctx context.Context, model string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
|
||||
return [][]byte{rawJSON}
|
||||
},
|
||||
})
|
||||
r.Register(from, nonStreamOnlyTo, nil, ResponseTransform{
|
||||
NonStream: func(ctx context.Context, model string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []byte {
|
||||
return rawJSON
|
||||
},
|
||||
})
|
||||
|
||||
if !r.HasResponseTransformer(from, streamOnlyTo) {
|
||||
t.Fatal("stream response transform was not reported as a response transformer")
|
||||
}
|
||||
if !r.HasStreamResponseTransformer(from, streamOnlyTo) {
|
||||
t.Fatal("stream response transform was not reported as a stream response transformer")
|
||||
}
|
||||
if r.HasNonStreamResponseTransformer(from, streamOnlyTo) {
|
||||
t.Fatal("stream-only transform was reported as a non-stream response transformer")
|
||||
}
|
||||
|
||||
if !r.HasResponseTransformer(from, nonStreamOnlyTo) {
|
||||
t.Fatal("non-stream response transform was not reported as a response transformer")
|
||||
}
|
||||
if r.HasStreamResponseTransformer(from, nonStreamOnlyTo) {
|
||||
t.Fatal("non-stream-only transform was reported as a stream response transformer")
|
||||
}
|
||||
if !r.HasNonStreamResponseTransformer(from, nonStreamOnlyTo) {
|
||||
t.Fatal("non-stream response transform was not reported as a non-stream response transformer")
|
||||
}
|
||||
|
||||
got := r.TranslateStream(ctx, streamOnlyTo, from, "model", nil, nil, []byte(`data: {"ok":true}`), nil)
|
||||
if len(got) != 1 || string(got[0]) != `data: {"ok":true}` {
|
||||
t.Fatalf("stream transform output = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateRequest_PluginTranslatorOnlyWhenNativeMissing(t *testing.T) {
|
||||
from := Format("from")
|
||||
to := Format("to")
|
||||
|
||||
missingNative := NewRegistry()
|
||||
missingHooks := &fakePluginHooks{
|
||||
requestTranslateBody: []byte(`{"model":"plugin-request"}`),
|
||||
requestTranslateOK: true,
|
||||
}
|
||||
missingNative.SetPluginHooks(missingHooks)
|
||||
|
||||
gotMissing := missingNative.TranslateRequest(from, to, "resolved", []byte(`{"model":"prefixed/resolved"}`), false)
|
||||
if gjson.GetBytes(gotMissing, "model").String() != "plugin-request" {
|
||||
t.Fatalf("plugin request translator was not used, got %s", gotMissing)
|
||||
}
|
||||
if !hasCall(missingHooks.calls, "translate-request") {
|
||||
t.Fatal("plugin request translator was not called when native transformer was missing")
|
||||
}
|
||||
|
||||
withNative := NewRegistry()
|
||||
nativeHooks := &fakePluginHooks{
|
||||
requestTranslateBody: []byte(`{"model":"plugin-request"}`),
|
||||
requestTranslateOK: true,
|
||||
}
|
||||
withNative.SetPluginHooks(nativeHooks)
|
||||
withNative.Register(from, to, func(model string, rawJSON []byte, stream bool) []byte {
|
||||
return []byte(`{"model":"native-request"}`)
|
||||
}, ResponseTransform{})
|
||||
|
||||
gotNative := withNative.TranslateRequest(from, to, "resolved", []byte(`{"model":"prefixed/resolved"}`), false)
|
||||
if gjson.GetBytes(gotNative, "model").String() != "native-request" {
|
||||
t.Fatalf("native request transformer was not preserved, got %s", gotNative)
|
||||
}
|
||||
if hasCall(nativeHooks.calls, "translate-request") {
|
||||
t.Fatal("plugin request translator was called despite native transformer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateNonStream_PluginTranslatorOnlyWhenNativeMissing(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
from := Format("client")
|
||||
to := Format("upstream")
|
||||
|
||||
missingNative := NewRegistry()
|
||||
missingHooks := &fakePluginHooks{
|
||||
responseTranslateBody: []byte(`{"output":"plugin-response"}`),
|
||||
responseTranslateOK: true,
|
||||
}
|
||||
missingNative.SetPluginHooks(missingHooks)
|
||||
|
||||
gotMissing := missingNative.TranslateNonStream(ctx, from, to, "model", nil, nil, []byte(`{"output":"raw"}`), nil)
|
||||
if gjson.GetBytes(gotMissing, "output").String() != "plugin-response" {
|
||||
t.Fatalf("plugin response translator was not used, got %s", gotMissing)
|
||||
}
|
||||
if !hasCall(missingHooks.calls, "translate-response") {
|
||||
t.Fatal("plugin response translator was not called when native transformer was missing")
|
||||
}
|
||||
|
||||
withNative := NewRegistry()
|
||||
nativeHooks := &fakePluginHooks{
|
||||
responseTranslateBody: []byte(`{"output":"plugin-response"}`),
|
||||
responseTranslateOK: true,
|
||||
}
|
||||
withNative.SetPluginHooks(nativeHooks)
|
||||
withNative.Register(to, from, nil, ResponseTransform{
|
||||
NonStream: func(ctx context.Context, model string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []byte {
|
||||
return []byte(`{"output":"native-response"}`)
|
||||
},
|
||||
})
|
||||
|
||||
gotNative := withNative.TranslateNonStream(ctx, from, to, "model", nil, nil, []byte(`{"output":"raw"}`), nil)
|
||||
if gjson.GetBytes(gotNative, "output").String() != "native-response" {
|
||||
t.Fatalf("native response transformer was not preserved, got %s", gotNative)
|
||||
}
|
||||
if hasCall(nativeHooks.calls, "translate-response") {
|
||||
t.Fatal("plugin response translator was called despite native transformer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateStream_NativeEmptyOutputSuppressesRawFallback(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
from := Format("client")
|
||||
to := Format("upstream")
|
||||
|
||||
r := NewRegistry()
|
||||
r.Register(to, from, nil, ResponseTransform{
|
||||
Stream: func(ctx context.Context, model string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
got := r.TranslateStream(ctx, from, to, "model", nil, nil, []byte(`data: {"raw":true}`), nil)
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("native stream transformer returned empty output, got raw fallback %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateStream_PluginTranslatorUsedWhenNativeStreamMissing(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
from := Format("client")
|
||||
to := Format("upstream")
|
||||
|
||||
r := NewRegistry()
|
||||
hooks := &fakePluginHooks{
|
||||
responseTranslateBody: []byte(`data: {"plugin":true}`),
|
||||
responseTranslateOK: true,
|
||||
}
|
||||
r.SetPluginHooks(hooks)
|
||||
r.Register(to, from, nil, ResponseTransform{
|
||||
NonStream: func(ctx context.Context, model string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []byte {
|
||||
return []byte(`{"native-non-stream":true}`)
|
||||
},
|
||||
})
|
||||
|
||||
got := r.TranslateStream(ctx, from, to, "model", nil, nil, []byte(`data: {"raw":true}`), nil)
|
||||
if len(got) != 1 || string(got[0]) != `data: {"plugin":true}` {
|
||||
t.Fatalf("plugin stream translator was not used, got %q", got)
|
||||
}
|
||||
if !hasCall(hooks.calls, "translate-response") {
|
||||
t.Fatal("plugin response translator was not called when native stream transformer was missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginNormalizersChainAfterNative(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r := NewRegistry()
|
||||
from := Format("client")
|
||||
to := Format("upstream")
|
||||
hooks := &fakePluginHooks{
|
||||
normalizeRequest: func(body []byte) []byte {
|
||||
if string(body) != `{"stage":"native-request"}` {
|
||||
t.Fatalf("request normalizer saw %s", body)
|
||||
}
|
||||
return []byte(`{"stage":"normalized-request"}`)
|
||||
},
|
||||
normalizeBefore: func(body []byte) []byte {
|
||||
if string(body) != `{"stage":"raw-response"}` {
|
||||
t.Fatalf("response before normalizer saw %s", body)
|
||||
}
|
||||
return []byte(`{"stage":"before-response"}`)
|
||||
},
|
||||
normalizeAfter: func(body []byte) []byte {
|
||||
if string(body) != `{"stage":"native-response"}` {
|
||||
t.Fatalf("response after normalizer saw %s", body)
|
||||
}
|
||||
return []byte(`{"stage":"after-response"}`)
|
||||
},
|
||||
}
|
||||
r.SetPluginHooks(hooks)
|
||||
r.Register(from, to, func(model string, rawJSON []byte, stream bool) []byte {
|
||||
return []byte(`{"stage":"native-request"}`)
|
||||
}, ResponseTransform{})
|
||||
r.Register(to, from, nil, ResponseTransform{
|
||||
NonStream: func(ctx context.Context, model string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []byte {
|
||||
if string(rawJSON) != `{"stage":"before-response"}` {
|
||||
t.Fatalf("native response transformer saw %s", rawJSON)
|
||||
}
|
||||
return []byte(`{"stage":"native-response"}`)
|
||||
},
|
||||
})
|
||||
|
||||
gotRequest := r.TranslateRequest(from, to, "model", []byte(`{"stage":"raw-request"}`), false)
|
||||
if string(gotRequest) != `{"stage":"normalized-request"}` {
|
||||
t.Fatalf("request normalizer did not run after native transformer, got %s", gotRequest)
|
||||
}
|
||||
|
||||
gotResponse := r.TranslateNonStream(ctx, from, to, "model", nil, nil, []byte(`{"stage":"raw-response"}`), nil)
|
||||
if string(gotResponse) != `{"stage":"after-response"}` {
|
||||
t.Fatalf("response normalizers did not wrap native transformer, got %s", gotResponse)
|
||||
}
|
||||
if hasCall(hooks.calls, "translate-request") || hasCall(hooks.calls, "translate-response") {
|
||||
t.Fatalf("plugin translators should not run when native transformers exist, calls=%v", hooks.calls)
|
||||
}
|
||||
}
|
||||
34
backend/sdk/translator/types.go
Normal file
34
backend/sdk/translator/types.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// Package translator provides types and functions for converting chat requests and responses between different schemas.
|
||||
package translator
|
||||
|
||||
import "context"
|
||||
|
||||
// RequestTransform is a function type that converts a request payload from a source schema to a target schema.
|
||||
// It takes the model name, the raw JSON payload of the request, and a boolean indicating if the request is for a streaming response.
|
||||
// It returns the converted request payload as a byte slice.
|
||||
type RequestTransform func(model string, rawJSON []byte, stream bool) []byte
|
||||
|
||||
// ResponseStreamTransform is a function type that converts a streaming response from a source schema to a target schema.
|
||||
// It takes a context, the model name, the raw JSON of the original and converted requests, the raw JSON of the current response chunk, and an optional parameter.
|
||||
// It returns a slice of byte chunks containing the converted streaming response.
|
||||
type ResponseStreamTransform func(ctx context.Context, model string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte
|
||||
|
||||
// ResponseNonStreamTransform is a function type that converts a non-streaming response from a source schema to a target schema.
|
||||
// It takes a context, the model name, the raw JSON of the original and converted requests, the raw JSON of the response, and an optional parameter.
|
||||
// It returns the converted response as a single byte slice.
|
||||
type ResponseNonStreamTransform func(ctx context.Context, model string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []byte
|
||||
|
||||
// ResponseTokenCountTransform is a function type that transforms a token count from a source format to a target format.
|
||||
// It takes a context and the token count as an int64, and returns the transformed token count as bytes.
|
||||
type ResponseTokenCountTransform func(ctx context.Context, count int64) []byte
|
||||
|
||||
// ResponseTransform is a struct that groups together the functions for transforming streaming and non-streaming responses,
|
||||
// as well as token counts.
|
||||
type ResponseTransform struct {
|
||||
// Stream is the function for transforming streaming responses.
|
||||
Stream ResponseStreamTransform
|
||||
// NonStream is the function for transforming non-streaming responses.
|
||||
NonStream ResponseNonStreamTransform
|
||||
// TokenCount is the function for transforming token counts.
|
||||
TokenCount ResponseTokenCountTransform
|
||||
}
|
||||
Loading…
Reference in a new issue