- 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 아카이브 정리
286 lines
7.1 KiB
Go
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 ""
|
|
}
|