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, 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"` 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 } 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 "" }