iop/apps/node/internal/adapters/openai_compat/openai_compat.go
toki 898da6e3af feat(node): openai compatible adapter implementation and test fixes
- Add openai_compat adapter package
- Move 02+01 OpenAI compatible adapter files to archive
- Fix adapter factory and test files
- Update blackbox tests
2026-06-15 14:12:40 +09:00

438 lines
12 KiB
Go

// Package openai_compat provides an Adapter for OpenAI-compatible inference
// servers such as Lemonade Server via the /v1/models and /v1/chat/completions
// endpoints. Unlike the vllm 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.
package openai_compat
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"go.uber.org/zap"
"iop/apps/node/internal/runtime"
"iop/packages/go/config"
)
const Name = "openai_compat"
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
}
// 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
}
}
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,
}
}
func (a *Adapter) Name() string { return Name }
func (a *Adapter) Capabilities(ctx context.Context) (runtime.Capabilities, error) {
probeCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
targets, err := a.fetchTargets(probeCtx)
status := runtime.ProviderStatusAvailable
if err != nil {
status = runtime.ProviderStatusUnavailable
}
return runtime.Capabilities{
AdapterName: Name,
InstanceKey: a.instanceName,
Targets: targets,
MaxConcurrency: effectiveCapacity(a.capacity, 8),
MaxQueue: a.maxQueue,
QueueTimeoutMS: a.queueTimeoutMS,
RequestTimeoutMS: a.requestTimeoutMS,
ProviderStatus: runtime.NormalizeProviderStatus(status),
}, nil
}
func effectiveCapacity(capacity, defaultVal int) int {
if capacity <= 0 {
return defaultVal
}
return capacity
}
func (a *Adapter) Execute(ctx context.Context, spec runtime.ExecutionSpec, sink runtime.EventSink) error {
if a.endpoint == "" {
return fmt.Errorf("openai_compat adapter: endpoint is required")
}
model := strings.TrimSpace(spec.Target)
if model == "" {
model = stringInput(spec.Input, "model")
}
if model == "" {
return fmt.Errorf("openai_compat adapter: target/model is required")
}
messages := messagesFromInput(spec.Input)
if len(messages) == 0 {
return fmt.Errorf("openai_compat adapter: messages are required")
}
if err := sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeStart,
Timestamp: time.Now(),
}); err != nil {
return err
}
reqBody := buildRequestBody(model, messages, spec.Input)
body, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("openai_compat adapter: marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinOpenAIPath(a.endpoint, "/v1/chat/completions"), bytes.NewReader(body))
if err != nil {
return fmt.Errorf("openai_compat adapter: build request: %w", err)
}
a.applyHeaders(req, true)
a.logger.Info("openai_compat adapter executing",
zap.String("run_id", spec.RunID),
zap.String("provider", a.provider),
zap.String("target", model),
zap.String("endpoint", a.endpoint),
)
resp, err := a.client.Do(req)
if err != nil {
_ = emitError(ctx, sink, spec.RunID, fmt.Sprintf("openai_compat request failed: %v", err))
return fmt.Errorf("openai_compat adapter: request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
msg := readLimited(resp.Body, 4096)
if msg == "" {
msg = resp.Status
}
_ = emitError(ctx, sink, spec.RunID, fmt.Sprintf("openai_compat returned %s: %s", resp.Status, msg))
return fmt.Errorf("openai_compat adapter: non-2xx response: %s", resp.Status)
}
scanner := bufio.NewScanner(resp.Body)
outputTokens := 0
finishReason := ""
var usage *runtime.UsageStats
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data: ") {
continue
}
payload := strings.TrimPrefix(line, "data: ")
if payload == "[DONE]" {
return sink.Emit(ctx, completeEvent(spec.RunID, finishReason, usage, outputTokens))
}
var chunk chatChunk
if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
continue
}
if chunk.Usage != nil {
usage = &runtime.UsageStats{
InputTokens: chunk.Usage.PromptTokens,
OutputTokens: chunk.Usage.CompletionTokens,
}
}
if len(chunk.Choices) == 0 {
continue
}
choice := chunk.Choices[0]
if choice.FinishReason != nil && *choice.FinishReason != "" {
finishReason = *choice.FinishReason
}
if reasoning := choice.Delta.ReasoningContent; reasoning != "" {
if err := sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeReasoningDelta,
Delta: reasoning,
Timestamp: time.Now(),
}); err != nil {
return err
}
}
if content := choice.Delta.Content; content != "" {
outputTokens += len(strings.Fields(content))
if err := sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeDelta,
Delta: content,
Timestamp: time.Now(),
}); err != nil {
return err
}
}
}
if err := scanner.Err(); err != nil {
_ = emitError(ctx, sink, spec.RunID, fmt.Sprintf("openai_compat stream scan failed: %v", err))
return fmt.Errorf("openai_compat adapter: scan stream: %w", err)
}
_ = emitError(ctx, sink, spec.RunID, "openai_compat stream ended without [DONE]")
return fmt.Errorf("openai_compat adapter: stream ended without [DONE]")
}
// ProbeProvider checks endpoint availability and target presence using the
// OpenAI-compatible /v1/models endpoint, returning only the public baseline
// status values.
func (a *Adapter) ProbeProvider(ctx context.Context, target string) (runtime.ProviderProbeResult, error) {
probeCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
targets, err := a.fetchTargets(probeCtx)
result := runtime.ProviderProbeResult{
AdapterName: Name,
InstanceKey: a.instanceName,
Target: target,
}
if a.provider != "" {
result.Metadata = map[string]string{"provider": a.provider}
}
if err != nil {
result.Status = runtime.NormalizeProviderStatus(runtime.ProviderStatusUnavailable)
result.Detail = err.Error()
return result, nil
}
result.Targets = targets
if target == "" {
result.Status = runtime.NormalizeProviderStatus(runtime.ProviderStatusAvailable)
return result, nil
}
found := false
for _, t := range targets {
if t == target {
found = true
break
}
}
if found {
result.Status = runtime.NormalizeProviderStatus(runtime.ProviderStatusAvailable)
} else {
result.Status = runtime.NormalizeProviderStatus(runtime.ProviderStatusUnavailable)
result.Detail = fmt.Sprintf("target model %q not found in provider models", target)
}
return result, nil
}
func (a *Adapter) fetchTargets(ctx context.Context) ([]string, error) {
if a.endpoint == "" {
return nil, fmt.Errorf("openai_compat adapter: endpoint is required")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, joinOpenAIPath(a.endpoint, "/v1/models"), nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
a.applyHeaders(req, false)
resp, err := a.client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("status code %d", resp.StatusCode)
}
var models modelsResponse
if err := json.NewDecoder(resp.Body).Decode(&models); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
out := make([]string, 0, len(models.Data))
for _, model := range models.Data {
if model.ID != "" {
out = append(out, model.ID)
}
}
return out, nil
}
// applyHeaders injects configured auth/secret headers. The adapter always owns
// Content-Type for JSON POSTs, so a user-provided Content-Type is ignored to
// keep the request body contract intact.
func (a *Adapter) applyHeaders(req *http.Request, jsonBody bool) {
for k, v := range a.headers {
if jsonBody && http.CanonicalHeaderKey(k) == "Content-Type" {
continue
}
req.Header.Set(k, v)
}
if jsonBody {
req.Header.Set("Content-Type", "application/json")
}
}
func buildRequestBody(model string, messages []chatMessage, input map[string]any) map[string]any {
body := make(map[string]any)
// Copy caller options as top-level OpenAI-compatible request fields first so
// adapter-owned fields below always win over them.
if opts, ok := input["options"].(map[string]any); ok {
for k, v := range opts {
body[k] = v
}
}
// Pass through optional OpenAI-compatible fields when present in the input.
for _, key := range []string{"tools", "format", "think", "keep_alive"} {
if v, ok := input[key]; ok {
body[key] = v
}
}
body["model"] = model
body["messages"] = messages
body["stream"] = true
return body
}
func completeEvent(runID, finishReason string, usage *runtime.UsageStats, outputTokens int) runtime.RuntimeEvent {
if usage == nil {
usage = &runtime.UsageStats{OutputTokens: outputTokens}
}
var metadata map[string]string
if finishReason != "" {
metadata = map[string]string{"finish_reason": finishReason}
}
return runtime.RuntimeEvent{
RunID: runID,
Type: runtime.EventTypeComplete,
Message: "openai_compat chat complete",
Usage: usage,
Metadata: metadata,
Timestamp: time.Now(),
}
}
func messagesFromInput(input map[string]any) []chatMessage {
if raw, ok := input["messages"].([]any); ok {
out := make([]chatMessage, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
role, _ := m["role"].(string)
content, _ := m["content"].(string)
role = strings.TrimSpace(role)
content = strings.TrimSpace(content)
if role == "" || content == "" {
continue
}
out = append(out, chatMessage{Role: role, Content: content})
}
if len(out) > 0 {
return out
}
}
if prompt := strings.TrimSpace(stringInput(input, "prompt")); prompt != "" {
return []chatMessage{{Role: "user", Content: prompt}}
}
return nil
}
func emitError(ctx context.Context, sink runtime.EventSink, runID, msg string) error {
return sink.Emit(ctx, runtime.RuntimeEvent{
RunID: runID,
Type: runtime.EventTypeError,
Error: msg,
Timestamp: time.Now(),
})
}
func stringInput(input map[string]any, key string) string {
if input == nil {
return ""
}
if v, ok := input[key].(string); ok {
return v
}
return ""
}
// joinOpenAIPath appends an OpenAI-compatible path to the endpoint. When the
// endpoint already targets the /v1 root it strips the leading /v1 from the path
// so a configured ".../v1" endpoint does not produce ".../v1/v1/...".
func joinOpenAIPath(baseURL, path string) string {
u, err := url.Parse(baseURL)
if err != nil {
return strings.TrimRight(baseURL, "/") + 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()
}
func readLimited(r io.Reader, limit int64) string {
b, _ := io.ReadAll(io.LimitReader(r, limit))
return strings.TrimSpace(string(b))
}
type chatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type chatChunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
} `json:"choices"`
Usage *struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
} `json:"usage"`
}
type modelsResponse struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}