iop/apps/node/internal/adapters/openai_compat/adapter.go
toki f2306f4dc8 feat(protocol-profile): 멀티 프로토콜 프로필을 추가한다
OpenAI 호환 및 Anthropic Messages 경로의 프로토콜 프로필, 라우팅 계약, 테스트와 실행 증거를 함께 반영한다.
2026-08-01 10:33:10 +09:00

111 lines
3.3 KiB
Go

package openai_compat
import (
"net/http"
"net/url"
"strings"
"go.uber.org/zap"
"iop/packages/go/config"
)
const Name = "openai_compat"
// Adapter is the OpenAI-compatible adapter. It carries an explicit provider
// label, per-request auth/header injection and top-level option passthrough so
// a provider boundary and its config contract stay distinct.
type Adapter struct {
instanceName string
provider string
endpoint string
headers map[string]string
capacity int
maxQueue int
queueTimeoutMS int
requestTimeoutMS int
client *http.Client
logger *zap.Logger
// profile is the resolved concrete protocol profile snapshot. When non-nil,
// the adapter resolves operation URLs from the profile's operation paths
// instead of the legacy endpoint + /v1 suffix heuristic.
profile *config.ConcreteProtocolProfile
}
// New creates an OpenAI-compatible adapter. The optional instanceName is the
// registry instance key used to disambiguate multiple instances on a node.
func New(cfg config.OpenAICompatConf, logger *zap.Logger, instanceName ...string) *Adapter {
endpoint := strings.TrimRight(cfg.Endpoint, "/")
name := Name
if len(instanceName) > 0 && instanceName[0] != "" {
name = instanceName[0]
}
var headers map[string]string
if len(cfg.Headers) > 0 {
headers = make(map[string]string, len(cfg.Headers))
for k, v := range cfg.Headers {
headers[k] = v
}
}
var profile *config.ConcreteProtocolProfile
if cfg.RuntimeProfile != nil {
cloned := cfg.RuntimeProfile.Clone()
profile = &cloned
}
return &Adapter{
instanceName: name,
provider: cfg.Provider,
endpoint: endpoint,
headers: headers,
capacity: cfg.Capacity,
maxQueue: cfg.MaxQueue,
queueTimeoutMS: cfg.QueueTimeoutMS,
requestTimeoutMS: cfg.RequestTimeoutMS,
client: &http.Client{},
logger: logger,
profile: profile,
}
}
func (a *Adapter) Name() string { return Name }
// operationURL resolves an operation id to its full URL. When the adapter has
// a concrete profile, the profile's operation path is used. When no profile
// is present (legacy payload), the endpoint is used with the legacy path
// appended.
func (a *Adapter) operationURL(op string, legacyPath string) (string, error) {
if a.profile != nil {
return a.profile.ResolveOperationURL(op)
}
if a.endpoint == "" {
return "", errEndpointRequired
}
return resolveLegacyURL(a.endpoint, legacyPath), nil
}
// resolveLegacyURL appends a legacy path to the endpoint, stripping a
// duplicate /v1 suffix to avoid /v1/v1.
func resolveLegacyURL(endpoint, path string) string {
u, err := url.Parse(endpoint)
if err != nil {
return strings.TrimRight(endpoint, "/") + path
}
basePath := strings.TrimRight(u.Path, "/")
if strings.HasSuffix(basePath, "/v1") && strings.HasPrefix(path, "/v1/") {
path = strings.TrimPrefix(path, "/v1")
}
u.Path = basePath + path
return u.String()
}
var errEndpointRequired = fmtError("openai_compat adapter: endpoint is required")
// fmtError is a small helper to avoid importing fmt just for error creation
// in the legacy fallback path.
func fmtError(msg string) error {
return &adapterError{msg: msg}
}
type adapterError struct{ msg string }
func (e *adapterError) Error() string { return e.msg }