nomadcode/services/core/internal/adapters/openai/client.go
toki fad5627ae6 feat: milestone work item creation/sync 구현 및 roadmap sync 기능 추가
- Plane Todo projection 및 idempotency/retry 메커니즘 구현
- Roadmap sync identity 및 step 모델/DB 마이그레이션/쿼리 추가
- OpenAI 및 Plane adapter 개선
- roadmaps sync package: plane_format, plane_projection, retry 모듈 추가
- Storage layer: roadmap_sync_identities, roadmap_sync_steps 추가
- agent-task 아카이브 정리
2026-06-14 04:08:15 +09:00

286 lines
7.1 KiB
Go

package openai
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"path"
"strings"
"time"
"github.com/nomadcode/nomadcode-core/internal/model"
)
const (
defaultAPIKey = "ollama"
defaultTimeoutSec = 300
responsesPath = "/v1/responses"
)
type Config struct {
BaseURL string
APIKey string
Model string
ContextSize int
TimeoutSec int
}
type Client struct {
cfg Config
httpClient *http.Client
logger *slog.Logger
}
func NewClient(cfg Config, logger *slog.Logger) *Client {
if cfg.APIKey == "" {
cfg.APIKey = defaultAPIKey
}
if cfg.TimeoutSec <= 0 {
cfg.TimeoutSec = defaultTimeoutSec
}
return &Client{
cfg: cfg,
httpClient: &http.Client{Timeout: time.Duration(cfg.TimeoutSec) * time.Second},
logger: logger,
}
}
func (c *Client) Generate(ctx context.Context, input model.GenerateInput) (model.GenerateResult, error) {
endpoint, err := responsesURL(c.cfg.BaseURL)
if err != nil {
return model.GenerateResult{}, err
}
modelName := strings.TrimSpace(input.Model)
if modelName == "" {
modelName = c.cfg.Model
}
if strings.TrimSpace(modelName) == "" {
return model.GenerateResult{}, fmt.Errorf("model name is required")
}
if strings.TrimSpace(input.Input) == "" {
return model.GenerateResult{}, fmt.Errorf("model input is required")
}
reqBody := responsesRequest{
Model: modelName,
Input: input.Input,
Instructions: input.Instructions,
Metadata: buildRequestMetadata(input),
Stream: false,
Temperature: input.Temperature,
TopP: input.TopP,
MaxOutputTokens: input.MaxOutputTokens,
}
if c.cfg.ContextSize > 0 {
reqBody.Options = &responsesOptions{NumCtx: c.cfg.ContextSize}
}
body, err := json.Marshal(reqBody)
if err != nil {
return model.GenerateResult{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return model.GenerateResult{}, err
}
req.Header.Set("Content-Type", "application/json")
if c.cfg.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+c.cfg.APIKey)
}
if c.logger != nil {
c.logger.Info(
"model responses request",
"endpoint", endpoint,
"model", modelName,
"num_ctx", c.cfg.ContextSize,
)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return model.GenerateResult{}, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil {
return model.GenerateResult{}, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return model.GenerateResult{}, responseError(resp.StatusCode, raw)
}
var parsed responsesResponse
if err := json.Unmarshal(raw, &parsed); err != nil {
return model.GenerateResult{}, err
}
usage := model.Usage{}
if parsed.Usage != nil {
usage.InputTokens = firstNonZero(parsed.Usage.InputTokens, parsed.Usage.PromptTokens)
usage.OutputTokens = firstNonZero(parsed.Usage.OutputTokens, parsed.Usage.CompletionTokens)
usage.TotalTokens = parsed.Usage.TotalTokens
if usage.TotalTokens == 0 {
usage.TotalTokens = usage.InputTokens + usage.OutputTokens
}
}
return model.GenerateResult{
ID: parsed.ID,
Model: firstNonEmpty(parsed.Model, modelName),
Text: parsed.text(),
Usage: usage,
Raw: json.RawMessage(raw),
}, nil
}
type responsesRequest struct {
Model string `json:"model"`
Input string `json:"input"`
Instructions string `json:"instructions,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
Stream bool `json:"stream"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
Options *responsesOptions `json:"options,omitempty"`
}
type responsesOptions struct {
NumCtx int `json:"num_ctx,omitempty"`
}
type responsesResponse struct {
ID string `json:"id"`
Model string `json:"model"`
OutputText string `json:"output_text"`
Output []responsesOutput `json:"output"`
Usage *responsesUsage `json:"usage"`
}
type responsesOutput struct {
Type string `json:"type"`
Text string `json:"text"`
Content []responsesContent `json:"content"`
}
type responsesContent struct {
Type string `json:"type"`
Text string `json:"text"`
}
type responsesUsage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
TotalTokens int `json:"total_tokens"`
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
}
func (r responsesResponse) text() string {
if r.OutputText != "" {
return r.OutputText
}
var b strings.Builder
for _, output := range r.Output {
if output.Text != "" {
b.WriteString(output.Text)
}
for _, content := range output.Content {
if content.Text != "" {
b.WriteString(content.Text)
}
}
}
return b.String()
}
type errorResponse struct {
Error struct {
Message string `json:"message"`
Type string `json:"type"`
Code string `json:"code"`
} `json:"error"`
}
func responseError(status int, raw []byte) error {
var parsed errorResponse
if err := json.Unmarshal(raw, &parsed); err == nil && parsed.Error.Message != "" {
return fmt.Errorf("responses API request failed: status %d: %s", status, parsed.Error.Message)
}
msg := strings.TrimSpace(string(raw))
if msg == "" {
msg = http.StatusText(status)
}
return fmt.Errorf("responses API request failed: status %d: %s", status, msg)
}
func responsesURL(base string) (string, error) {
base = strings.TrimSpace(base)
if base == "" {
return "", fmt.Errorf("model base URL is required")
}
parsed, err := url.Parse(base)
if err != nil {
return "", err
}
if parsed.Scheme == "" || parsed.Host == "" {
return "", fmt.Errorf("model base URL must include scheme and host")
}
cleanPath := strings.TrimRight(parsed.Path, "/")
switch {
case strings.HasSuffix(cleanPath, "/v1/responses"):
parsed.Path = cleanPath
case strings.HasSuffix(cleanPath, "/v1"):
parsed.Path = path.Join(cleanPath, "responses")
default:
parsed.Path = path.Join(cleanPath, responsesPath)
}
return parsed.String(), nil
}
// buildRequestMetadata merges flat string metadata with typed WorkspaceMetadata.
// WorkspaceMetadata wins on key collision ("workspace" key).
func buildRequestMetadata(input model.GenerateInput) map[string]any {
if len(input.Metadata) == 0 && input.WorkspaceMetadata == nil {
return nil
}
merged := make(map[string]any, len(input.Metadata)+1)
for k, v := range input.Metadata {
merged[k] = v
}
if input.WorkspaceMetadata != nil {
if path := strings.TrimSpace(input.WorkspaceMetadata.Path); path != "" {
merged["workspace"] = path
}
}
return merged
}
func firstNonZero(values ...int) int {
for _, value := range values {
if value != 0 {
return value
}
}
return 0
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}