Add OpenAI-compatible model client
This commit is contained in:
86
docs/integrations/openai-compatible-chat.md
Normal file
86
docs/integrations/openai-compatible-chat.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# OpenAI-Compatible Chat Integration
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document defines the outbound HTTP behavior implemented by Promptkit's
|
||||||
|
internal OpenAI-compatible model client. The
|
||||||
|
[internal model-client document](../internal/llm.md) owns implementation flow,
|
||||||
|
errors, and test ownership. The client is not yet available through a usable
|
||||||
|
public Promptkit engine.
|
||||||
|
|
||||||
|
## Endpoint And Method
|
||||||
|
|
||||||
|
Generation sends an HTTP `POST` with `Content-Type: application/json`.
|
||||||
|
A non-empty endpoint from the execution target overrides the client's
|
||||||
|
configured base URL. After trailing slashes are removed,
|
||||||
|
`/chat/completions` is appended. Generation fails before sending when neither
|
||||||
|
source supplies an endpoint.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
A non-empty API key supplied directly on the execution target takes
|
||||||
|
precedence. Otherwise, when an API-key environment-variable name is supplied,
|
||||||
|
the client reads that variable and requires a non-empty value. The selected
|
||||||
|
key is sent as `Authorization: Bearer <key>`. No authorization header is sent
|
||||||
|
when neither mechanism is configured.
|
||||||
|
|
||||||
|
## Request Body
|
||||||
|
|
||||||
|
The request body always contains `model` and `messages`. The execution
|
||||||
|
target's model takes precedence over the client's configured model, and one
|
||||||
|
must be available.
|
||||||
|
|
||||||
|
Each ordinary message contains its `role` and string `content`. A
|
||||||
|
cache-controlled message instead uses a text content block containing `type`,
|
||||||
|
`text`, and `cache_control`; an empty cache-control TTL is omitted.
|
||||||
|
|
||||||
|
A non-empty session ID is trimmed, checked against the internal domain limit,
|
||||||
|
and sent as top-level `session_id`. It is not sent as a session header.
|
||||||
|
|
||||||
|
The client conditionally includes:
|
||||||
|
|
||||||
|
- `temperature`, `max_tokens`, and `top_p` when non-zero or explicitly
|
||||||
|
present;
|
||||||
|
- non-empty `service_tier` and `reasoning_effort`; and
|
||||||
|
- `response_format` for JSON Schema structured output, including its name,
|
||||||
|
strict flag, and schema document.
|
||||||
|
|
||||||
|
Extra parameters are merged directly into the top-level body after JSON
|
||||||
|
serialization is verified. Empty keys and collisions with these reserved
|
||||||
|
fields are rejected before any provider call:
|
||||||
|
|
||||||
|
- `model`
|
||||||
|
- `session_id`
|
||||||
|
- `messages`
|
||||||
|
- `temperature`
|
||||||
|
- `max_tokens`
|
||||||
|
- `top_p`
|
||||||
|
- `service_tier`
|
||||||
|
- `reasoning_effort`
|
||||||
|
- `response_format`
|
||||||
|
|
||||||
|
## Response Handling
|
||||||
|
|
||||||
|
Any 2xx response is decoded as an OpenAI-compatible chat response. The client
|
||||||
|
returns the first choice's non-empty message content and maps prompt,
|
||||||
|
completion, total, cached, and cache-write token counts.
|
||||||
|
|
||||||
|
Invalid JSON, absent choices, and empty first-choice content are malformed
|
||||||
|
responses. For a non-2xx status, the error includes the status code but never
|
||||||
|
the provider response body.
|
||||||
|
|
||||||
|
## Timeout And Cancellation
|
||||||
|
|
||||||
|
Timeouts are layered:
|
||||||
|
|
||||||
|
- the caller context remains the outer cancellation boundary;
|
||||||
|
- a positive generation timeout adds a request context deadline;
|
||||||
|
- zero adds no generation-specific deadline;
|
||||||
|
- a negative generation timeout is invalid; and
|
||||||
|
- the cloned `http.Client` supplies the whole-request transport cap, retaining
|
||||||
|
a positive supplied-client timeout or applying the configured/default
|
||||||
|
timeout when the supplied value is not positive.
|
||||||
|
|
||||||
|
The earliest applicable caller, generation, or transport deadline controls the
|
||||||
|
request. Constructing the internal client does not mutate a supplied
|
||||||
|
`http.Client`.
|
||||||
53
docs/internal/llm.md
Normal file
53
docs/internal/llm.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Internal Model Client
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document describes Promptkit's internal model-client implementation. The
|
||||||
|
[architecture policy](../policy/architecture.md) owns the library boundary,
|
||||||
|
and the
|
||||||
|
[OpenAI-compatible chat integration](../integrations/openai-compatible-chat.md)
|
||||||
|
owns the observable outbound HTTP contract.
|
||||||
|
|
||||||
|
The client is implemented only under `internal/llm`. The root package does not
|
||||||
|
yet assemble it into a usable public engine.
|
||||||
|
|
||||||
|
## Components And Flow
|
||||||
|
|
||||||
|
`Client` is the provider-neutral generation boundary consumed by later
|
||||||
|
orchestration. `OpenAICompatibleClient` is the built-in implementation. It
|
||||||
|
uses internal domain values for rendered prompts, execution targets,
|
||||||
|
structured output, responses, and token usage.
|
||||||
|
|
||||||
|
Construction validates the configured base URL and clones any supplied
|
||||||
|
`http.Client` so Promptkit can apply its timeout default without mutating the
|
||||||
|
caller's client. Generation then:
|
||||||
|
|
||||||
|
1. validates request-level timeout and endpoint requirements;
|
||||||
|
2. maps the internal request into the OpenAI-compatible chat payload;
|
||||||
|
3. validates and merges extra parameters;
|
||||||
|
4. resolves authentication;
|
||||||
|
5. performs the outbound request under the applicable deadlines; and
|
||||||
|
6. decodes the first response choice and token usage.
|
||||||
|
|
||||||
|
The implementation has no retry loop, tool-call support, provider catalog,
|
||||||
|
inbound HTTP behavior, or durable session store.
|
||||||
|
|
||||||
|
## Failure Categories
|
||||||
|
|
||||||
|
The package preserves distinct error identities for invalid client
|
||||||
|
configuration, invalid generation requests, request execution failures,
|
||||||
|
non-success provider statuses, and malformed successful responses. Provider
|
||||||
|
response bodies are not included in non-success errors.
|
||||||
|
|
||||||
|
Caller cancellation and deadline failures during the outbound request are
|
||||||
|
reported as request execution failures. The future runner can classify these
|
||||||
|
identities without depending on HTTP status mapping.
|
||||||
|
|
||||||
|
## Test Ownership
|
||||||
|
|
||||||
|
The
|
||||||
|
[OpenAI-compatible client tests](../../internal/llm/openai_compatible_client_test.go)
|
||||||
|
own configuration, client cloning, deterministic deadline precedence,
|
||||||
|
authentication, request and response mapping, malformed data, error identity,
|
||||||
|
cancellation, and response-body suppression. They use local test servers and
|
||||||
|
test transports; the default suite makes no live or paid provider requests.
|
||||||
@@ -21,10 +21,11 @@ contributor workflow and validation.
|
|||||||
| `internal/prompt` | Renders prompt messages from Go templates with artifact, variable, session, and cache-control data. | [Go-template renderer](../../internal/prompt/go_renderer.go) |
|
| `internal/prompt` | Renders prompt messages from Go templates with artifact, variable, session, and cache-control data. | [Go-template renderer](../../internal/prompt/go_renderer.go) |
|
||||||
| `internal/artifact` | Resolves ordinary inline and unrestricted caller-selected file references into copied artifacts with metadata and hashes. | [Internal sources and validation](sources.md) |
|
| `internal/artifact` | Resolves ordinary inline and unrestricted caller-selected file references into copied artifacts with metadata and hashes. | [Internal sources and validation](sources.md) |
|
||||||
| `internal/validate` | Validates basic, JSON, and JSON Schema output using operating-system filesystem or `fs.FS` schema sources. | [Internal sources and validation](sources.md) |
|
| `internal/validate` | Validates basic, JSON, and JSON Schema output using operating-system filesystem or `fs.FS` schema sources. | [Internal sources and validation](sources.md) |
|
||||||
|
| `internal/llm` | Defines the internal generation boundary and implements outbound OpenAI-compatible chat requests, response decoding, authentication, and deadline handling. | [Internal model client](llm.md) |
|
||||||
|
|
||||||
These packages provide the internal model, source, and rendering foundation.
|
These packages provide the internal model, source, rendering, validation, and
|
||||||
Model clients, orchestration, and a usable public engine are not implemented in
|
model-client foundation. Orchestration and a usable public engine are not
|
||||||
Promptkit yet.
|
implemented in Promptkit yet.
|
||||||
|
|
||||||
## Maintenance
|
## Maintenance
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ The implemented internal components consist of:
|
|||||||
- `internal/domain`, which owns framework data values shared by later internal
|
- `internal/domain`, which owns framework data values shared by later internal
|
||||||
components;
|
components;
|
||||||
- `internal/defaults`, which owns application-neutral framework defaults and
|
- `internal/defaults`, which owns application-neutral framework defaults and
|
||||||
constructs the default execution target; and
|
constructs the default execution target;
|
||||||
- `internal/filecatalog`, which discovers YAML files and provides source-path
|
- `internal/filecatalog`, which discovers YAML files and provides source-path
|
||||||
helpers for filesystem and `fs.FS` consumers;
|
helpers for filesystem and `fs.FS` consumers;
|
||||||
- `internal/promptdef`, which loads and validates prompt definitions from
|
- `internal/promptdef`, which loads and validates prompt definitions from
|
||||||
@@ -32,17 +32,20 @@ The implemented internal components consist of:
|
|||||||
catalog;
|
catalog;
|
||||||
- `internal/prompt`, which renders prompt messages from Go templates;
|
- `internal/prompt`, which renders prompt messages from Go templates;
|
||||||
- `internal/artifact`, which resolves ordinary inline and unrestricted
|
- `internal/artifact`, which resolves ordinary inline and unrestricted
|
||||||
caller-selected file references; and
|
caller-selected file references;
|
||||||
- `internal/validate`, which validates basic, JSON, and JSON Schema output
|
- `internal/validate`, which validates basic, JSON, and JSON Schema output
|
||||||
using filesystem and `fs.FS` schema sources.
|
using filesystem and `fs.FS` schema sources; and
|
||||||
|
- `internal/llm`, which defines the provider-neutral generation boundary and
|
||||||
|
implements outbound OpenAI-compatible chat requests.
|
||||||
|
|
||||||
The defaults and renderer depend on the domain model. Prompt-definition and
|
The defaults and renderer depend on the domain model. Prompt-definition and
|
||||||
profile repositories use the domain model, file catalog, and YAML decoder. The
|
profile repositories use the domain model, file catalog, and YAML decoder. The
|
||||||
built-in profile repository supplies an embedded `fs.FS` to the profile
|
built-in profile repository supplies an embedded `fs.FS` to the profile
|
||||||
package. Artifact reading uses the domain model and application-neutral
|
package. Artifact reading uses the domain model and application-neutral
|
||||||
defaults. Validation uses the domain model, file catalog, and JSON Schema
|
defaults. Validation uses the domain model, file catalog, and JSON Schema
|
||||||
implementation. Model clients, orchestration, and the public engine have not
|
implementation. The model client uses the domain model, application-neutral
|
||||||
yet been extracted.
|
defaults, and an injected or standard-library HTTP client. Orchestration and
|
||||||
|
the public engine have not yet been extracted.
|
||||||
|
|
||||||
Future framework extraction must follow this dependency direction:
|
Future framework extraction must follow this dependency direction:
|
||||||
|
|
||||||
|
|||||||
11
internal/llm/client.go
Normal file
11
internal/llm/client.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client executes a rendered prompt against an LLM endpoint.
|
||||||
|
type Client interface {
|
||||||
|
Generate(ctx context.Context, req domain.GenerateRequest) (*domain.GenerateResponse, error)
|
||||||
|
}
|
||||||
385
internal/llm/openai_compatible_client.go
Normal file
385
internal/llm/openai_compatible_client.go
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/defaults"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidConfig = errors.New("invalid llm client configuration")
|
||||||
|
ErrInvalidRequest = errors.New("invalid generate request")
|
||||||
|
ErrRequestFailed = errors.New("llm request failed")
|
||||||
|
ErrUnexpectedStatus = errors.New("llm returned non-success status")
|
||||||
|
ErrMalformedResponse = errors.New("malformed llm response")
|
||||||
|
)
|
||||||
|
|
||||||
|
type OpenAICompatibleConfig struct {
|
||||||
|
BaseURL string
|
||||||
|
Model string
|
||||||
|
Timeout time.Duration
|
||||||
|
HTTPClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpenAICompatibleClient struct {
|
||||||
|
baseURL string
|
||||||
|
defaultModel string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOpenAICompatibleClient(cfg OpenAICompatibleConfig) (*OpenAICompatibleClient, error) {
|
||||||
|
baseURL := strings.TrimSpace(cfg.BaseURL)
|
||||||
|
if baseURL != "" {
|
||||||
|
if _, err := url.ParseRequestURI(baseURL); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: invalid base URL: %v", ErrInvalidConfig, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := cfg.Timeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = defaults.LLMRequestTimeoutDefault
|
||||||
|
}
|
||||||
|
|
||||||
|
var client *http.Client
|
||||||
|
if cfg.HTTPClient != nil {
|
||||||
|
cloned := *cfg.HTTPClient
|
||||||
|
if cloned.Timeout <= 0 {
|
||||||
|
cloned.Timeout = timeout
|
||||||
|
}
|
||||||
|
client = &cloned
|
||||||
|
} else {
|
||||||
|
client = &http.Client{Timeout: timeout}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &OpenAICompatibleClient{
|
||||||
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
defaultModel: cfg.Model,
|
||||||
|
httpClient: client,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OpenAICompatibleClient) Generate(ctx context.Context, req domain.GenerateRequest) (*domain.GenerateResponse, error) {
|
||||||
|
if req.Target.TimeoutSeconds < 0 {
|
||||||
|
return nil, fmt.Errorf("%w: timeout_seconds must be greater than or equal to 0", ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := strings.TrimSpace(req.Target.Endpoint)
|
||||||
|
if endpoint == "" {
|
||||||
|
endpoint = c.baseURL
|
||||||
|
}
|
||||||
|
if endpoint == "" {
|
||||||
|
return nil, fmt.Errorf("%w: endpoint is required", ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
endpoint = strings.TrimRight(endpoint, "/") + defaults.OpenAIChatCompletionsPath
|
||||||
|
|
||||||
|
wireReq, err := openAIChatRequestFromGenerateRequest(req, c.defaultModel)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wirePayload, err := openAIChatRequestPayload(wireReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(wirePayload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: failed to encode request: %v", ErrRequestFailed, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
requestContext := ctx
|
||||||
|
if req.Target.TimeoutSeconds > 0 {
|
||||||
|
var cancel context.CancelFunc
|
||||||
|
requestContext, cancel = context.WithTimeout(
|
||||||
|
ctx,
|
||||||
|
time.Duration(req.Target.TimeoutSeconds)*time.Second,
|
||||||
|
)
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequestWithContext(requestContext, http.MethodPost, endpoint, bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: failed to create request: %v", ErrRequestFailed, err)
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
if apiKey := strings.TrimSpace(req.Target.APIKey); apiKey != "" {
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
} else if envName := strings.TrimSpace(req.Target.APIKeyEnv); envName != "" {
|
||||||
|
apiKey := strings.TrimSpace(os.Getenv(envName))
|
||||||
|
if apiKey == "" {
|
||||||
|
return nil, fmt.Errorf("%w: api key environment variable %q is not set", ErrInvalidRequest, envName)
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpClient := c.httpClient
|
||||||
|
if httpClient == nil {
|
||||||
|
httpClient = &http.Client{Timeout: defaults.LLMRequestTimeoutDefault}
|
||||||
|
}
|
||||||
|
|
||||||
|
httpResp, err := httpClient.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrRequestFailed, err)
|
||||||
|
}
|
||||||
|
defer httpResp.Body.Close()
|
||||||
|
|
||||||
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||||
|
_, _ = io.Copy(io.Discard, io.LimitReader(httpResp.Body, 4096))
|
||||||
|
return nil, fmt.Errorf("%w: status=%d", ErrUnexpectedStatus, httpResp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var wireResp openAIChatResponse
|
||||||
|
if err := json.NewDecoder(httpResp.Body).Decode(&wireResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: failed to decode response: %v", ErrMalformedResponse, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(wireResp.Choices) == 0 {
|
||||||
|
return nil, fmt.Errorf("%w: no choices returned", ErrMalformedResponse)
|
||||||
|
}
|
||||||
|
content := wireResp.Choices[0].Message.Content
|
||||||
|
if content == "" {
|
||||||
|
return nil, fmt.Errorf("%w: first choice has empty message content", ErrMalformedResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &domain.GenerateResponse{
|
||||||
|
Content: content,
|
||||||
|
Usage: domain.TokenUsage{
|
||||||
|
PromptTokens: wireResp.Usage.PromptTokens,
|
||||||
|
CompletionTokens: wireResp.Usage.CompletionTokens,
|
||||||
|
TotalTokens: wireResp.Usage.TotalTokens,
|
||||||
|
CachedTokens: wireResp.Usage.PromptTokensDetails.CachedTokens,
|
||||||
|
CacheWriteTokens: wireResp.Usage.CacheWriteTokens,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func openAIChatRequestFromGenerateRequest(req domain.GenerateRequest, defaultModel string) (openAIChatRequest, error) {
|
||||||
|
model := strings.TrimSpace(req.Target.Model)
|
||||||
|
if model == "" {
|
||||||
|
model = strings.TrimSpace(defaultModel)
|
||||||
|
}
|
||||||
|
if model == "" {
|
||||||
|
return openAIChatRequest{}, errors.New("model is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
wireReq := openAIChatRequest{
|
||||||
|
Model: model,
|
||||||
|
}
|
||||||
|
if sessionID := strings.TrimSpace(req.Prompt.SessionID); sessionID != "" {
|
||||||
|
if n := utf8.RuneCountInString(sessionID); n > domain.SessionIDMaxLength {
|
||||||
|
return openAIChatRequest{}, fmt.Errorf("session_id length %d exceeds maximum %d", n, domain.SessionIDMaxLength)
|
||||||
|
}
|
||||||
|
wireReq.SessionID = sessionID
|
||||||
|
}
|
||||||
|
|
||||||
|
wireReq.Messages = make([]openAIChatRequestMessage, 0, len(req.Prompt.Messages))
|
||||||
|
for _, msg := range req.Prompt.Messages {
|
||||||
|
wireReq.Messages = append(wireReq.Messages, openAIChatRequestMessageFromRenderedMessage(msg))
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Target.Temperature != 0 || req.TargetPresence.Temperature {
|
||||||
|
wireReq.Temperature = &req.Target.Temperature
|
||||||
|
}
|
||||||
|
if req.Target.MaxTokens != 0 || req.TargetPresence.MaxTokens {
|
||||||
|
wireReq.MaxTokens = &req.Target.MaxTokens
|
||||||
|
}
|
||||||
|
if req.Target.TopP != 0 || req.TargetPresence.TopP {
|
||||||
|
wireReq.TopP = &req.Target.TopP
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Target.ServiceTier) != "" {
|
||||||
|
wireReq.ServiceTier = req.Target.ServiceTier
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Target.ReasoningEffort) != "" {
|
||||||
|
wireReq.ReasoningEffort = req.Target.ReasoningEffort
|
||||||
|
}
|
||||||
|
if len(req.Target.ExtraParams) > 0 {
|
||||||
|
wireReq.ExtraParams = req.Target.ExtraParams
|
||||||
|
}
|
||||||
|
if req.StructuredOutput != nil {
|
||||||
|
responseFormat, err := toOpenAIResponseFormat(req.StructuredOutput)
|
||||||
|
if err != nil {
|
||||||
|
return openAIChatRequest{}, err
|
||||||
|
}
|
||||||
|
wireReq.ResponseFormat = responseFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
return wireReq, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type openAIChatRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
SessionID string `json:"session_id,omitempty"`
|
||||||
|
Messages []openAIChatRequestMessage `json:"messages"`
|
||||||
|
Temperature *float64 `json:"temperature,omitempty"`
|
||||||
|
MaxTokens *int `json:"max_tokens,omitempty"`
|
||||||
|
TopP *float64 `json:"top_p,omitempty"`
|
||||||
|
ServiceTier string `json:"service_tier,omitempty"`
|
||||||
|
ReasoningEffort string `json:"reasoning_effort,omitempty"`
|
||||||
|
ResponseFormat *openAIResponseFormat `json:"response_format,omitempty"`
|
||||||
|
ExtraParams map[string]any `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func openAIChatRequestPayload(req openAIChatRequest) (map[string]any, error) {
|
||||||
|
out := map[string]any{
|
||||||
|
"model": req.Model,
|
||||||
|
"messages": req.Messages,
|
||||||
|
}
|
||||||
|
if req.SessionID != "" {
|
||||||
|
out["session_id"] = req.SessionID
|
||||||
|
}
|
||||||
|
if req.Temperature != nil {
|
||||||
|
out["temperature"] = *req.Temperature
|
||||||
|
}
|
||||||
|
if req.MaxTokens != nil {
|
||||||
|
out["max_tokens"] = *req.MaxTokens
|
||||||
|
}
|
||||||
|
if req.TopP != nil {
|
||||||
|
out["top_p"] = *req.TopP
|
||||||
|
}
|
||||||
|
if req.ServiceTier != "" {
|
||||||
|
out["service_tier"] = req.ServiceTier
|
||||||
|
}
|
||||||
|
if req.ReasoningEffort != "" {
|
||||||
|
out["reasoning_effort"] = req.ReasoningEffort
|
||||||
|
}
|
||||||
|
if req.ResponseFormat != nil {
|
||||||
|
out["response_format"] = req.ResponseFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value := range req.ExtraParams {
|
||||||
|
if key == "" {
|
||||||
|
return nil, errors.New("extra_params key must not be empty")
|
||||||
|
}
|
||||||
|
if _, reserved := reservedOpenAIChatRequestFields[key]; reserved {
|
||||||
|
return nil, fmt.Errorf("extra_params key %q collides with reserved request field", key)
|
||||||
|
}
|
||||||
|
if _, err := json.Marshal(value); err != nil {
|
||||||
|
return nil, fmt.Errorf("extra_params.%s must be JSON-serializable: %w", key, err)
|
||||||
|
}
|
||||||
|
out[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var reservedOpenAIChatRequestFields = map[string]struct{}{
|
||||||
|
"model": {},
|
||||||
|
"session_id": {},
|
||||||
|
"messages": {},
|
||||||
|
"temperature": {},
|
||||||
|
"max_tokens": {},
|
||||||
|
"top_p": {},
|
||||||
|
"service_tier": {},
|
||||||
|
"reasoning_effort": {},
|
||||||
|
"response_format": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
type openAIChatRequestMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content any `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openAIChatTextContentBlock struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
CacheControl *openAICacheControl `json:"cache_control,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openAICacheControl struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
TTL string `json:"ttl,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openAIChatResponseMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openAIChatResponse struct {
|
||||||
|
Choices []struct {
|
||||||
|
Message openAIChatResponseMessage `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
Usage struct {
|
||||||
|
PromptTokens int `json:"prompt_tokens"`
|
||||||
|
CompletionTokens int `json:"completion_tokens"`
|
||||||
|
TotalTokens int `json:"total_tokens"`
|
||||||
|
PromptTokensDetails struct {
|
||||||
|
CachedTokens int `json:"cached_tokens"`
|
||||||
|
} `json:"prompt_tokens_details"`
|
||||||
|
CacheWriteTokens int `json:"cache_write_tokens"`
|
||||||
|
} `json:"usage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openAIResponseFormat struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
JSONSchema *openAIJSONSchemaEnvelope `json:"json_schema,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openAIJSONSchemaEnvelope struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Strict bool `json:"strict"`
|
||||||
|
Schema any `json:"schema"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func openAIChatRequestMessageFromRenderedMessage(msg domain.RenderedMessage) openAIChatRequestMessage {
|
||||||
|
wireMsg := openAIChatRequestMessage{
|
||||||
|
Role: msg.Role,
|
||||||
|
Content: msg.Content,
|
||||||
|
}
|
||||||
|
if msg.CacheControl == nil {
|
||||||
|
return wireMsg
|
||||||
|
}
|
||||||
|
|
||||||
|
wireMsg.Content = []openAIChatTextContentBlock{
|
||||||
|
{
|
||||||
|
Type: "text",
|
||||||
|
Text: msg.Content,
|
||||||
|
CacheControl: &openAICacheControl{
|
||||||
|
Type: string(msg.CacheControl.Type),
|
||||||
|
TTL: msg.CacheControl.TTL,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return wireMsg
|
||||||
|
}
|
||||||
|
|
||||||
|
func toOpenAIResponseFormat(spec *domain.StructuredOutputSpec) (*openAIResponseFormat, error) {
|
||||||
|
if spec == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch spec.Type {
|
||||||
|
case domain.StructuredOutputJSONSchema:
|
||||||
|
if spec.JSONSchema == nil {
|
||||||
|
return nil, errors.New("json_schema structured output requires schema payload")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(spec.JSONSchema.Name) == "" {
|
||||||
|
return nil, errors.New("json_schema structured output requires non-empty schema name")
|
||||||
|
}
|
||||||
|
if spec.JSONSchema.Schema == nil {
|
||||||
|
return nil, errors.New("json_schema structured output requires schema document")
|
||||||
|
}
|
||||||
|
return &openAIResponseFormat{
|
||||||
|
Type: "json_schema",
|
||||||
|
JSONSchema: &openAIJSONSchemaEnvelope{
|
||||||
|
Name: spec.JSONSchema.Name,
|
||||||
|
Strict: spec.JSONSchema.Strict,
|
||||||
|
Schema: spec.JSONSchema.Schema,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported structured output type %q", spec.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
1192
internal/llm/openai_compatible_client_test.go
Normal file
1192
internal/llm/openai_compatible_client_test.go
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user