Add projects

This commit is contained in:
Alois 2026-08-24 00:10:41 +02:00
commit 8b607dd700
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
1802 changed files with 503346 additions and 2 deletions

View 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())
}

View 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)
}

View 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"
)

View 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)
}

View 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)
}

View 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
}

View 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
}

View 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)
}
}

View 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)
}
}

View 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)
}
}

View 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
}