Added support for OpenAI-compatible structured output
This commit is contained in:
@@ -113,6 +113,13 @@ func (c *OpenAICompatibleClient) Generate(ctx context.Context, req domain.Genera
|
||||
if req.Target.TopP != 0 {
|
||||
wireReq.TopP = &req.Target.TopP
|
||||
}
|
||||
if req.StructuredOutput != nil {
|
||||
responseFormat, err := toOpenAIResponseFormat(req.StructuredOutput)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
|
||||
}
|
||||
wireReq.ResponseFormat = responseFormat
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(wireReq)
|
||||
if err != nil {
|
||||
@@ -181,11 +188,12 @@ func (c *OpenAICompatibleClient) Generate(ctx context.Context, req domain.Genera
|
||||
}
|
||||
|
||||
type openAIChatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []openAIChatMessage `json:"messages"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
MaxTokens *int `json:"max_tokens,omitempty"`
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Messages []openAIChatMessage `json:"messages"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
MaxTokens *int `json:"max_tokens,omitempty"`
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
ResponseFormat *openAIResponseFormat `json:"response_format,omitempty"`
|
||||
}
|
||||
|
||||
type openAIChatMessage struct {
|
||||
@@ -203,3 +211,43 @@ type openAIChatResponse struct {
|
||||
TotalTokens int `json:"total_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 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,20 @@ func TestOpenAICompatibleClientGenerateSuccess(t *testing.T) {
|
||||
TopP: 0.7,
|
||||
APIKeyEnv: "SCRIPTORIUM_TEST_API_KEY",
|
||||
},
|
||||
StructuredOutput: &domain.StructuredOutputSpec{
|
||||
Type: domain.StructuredOutputJSONSchema,
|
||||
JSONSchema: &domain.StructuredOutputJSONSpec{
|
||||
Name: "weather_schema",
|
||||
Strict: true,
|
||||
Schema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"location": map[string]any{"type": "string"},
|
||||
},
|
||||
"required": []any{"location"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
@@ -94,6 +108,55 @@ func TestOpenAICompatibleClientGenerateSuccess(t *testing.T) {
|
||||
if msg1["role"] != "user" || msg1["content"] != "Say hello" {
|
||||
t.Fatalf("unexpected second message: %#v", msg1)
|
||||
}
|
||||
|
||||
responseFormat, ok := obs.Body["response_format"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected response_format payload, got %#v", obs.Body["response_format"])
|
||||
}
|
||||
if responseFormat["type"] != "json_schema" {
|
||||
t.Fatalf("expected response_format.type=json_schema, got %#v", responseFormat["type"])
|
||||
}
|
||||
jsonSchema, ok := responseFormat["json_schema"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected response_format.json_schema map, got %#v", responseFormat["json_schema"])
|
||||
}
|
||||
if jsonSchema["name"] != "weather_schema" {
|
||||
t.Fatalf("expected json_schema.name weather_schema, got %#v", jsonSchema["name"])
|
||||
}
|
||||
if jsonSchema["strict"] != true {
|
||||
t.Fatalf("expected json_schema.strict=true, got %#v", jsonSchema["strict"])
|
||||
}
|
||||
if _, ok := jsonSchema["schema"].(map[string]any); !ok {
|
||||
t.Fatalf("expected json_schema.schema object, got %#v", jsonSchema["schema"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAICompatibleClientOmitsResponseFormatWhenNoStructuredOutput(t *testing.T) {
|
||||
var observedBody map[string]any
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
if err := json.NewDecoder(r.Body).Decode(&observedBody); err != nil {
|
||||
t.Fatalf("failed to decode request body: %v", err)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{BaseURL: ts.URL + "/v1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
||||
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
||||
Target: domain.ExecutionTarget{Model: "model"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if _, exists := observedBody["response_format"]; exists {
|
||||
t.Fatalf("expected response_format omitted, got %#v", observedBody["response_format"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAICompatibleClientNoAuthorizationHeaderWhenNoAPIKey(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user