Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/docs/developers/build/connectors/services/openai.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,29 @@ For details on managing credentials across environments, see [Configure Local Cr

For additional configuration options (model, base URL, API type, etc.), see the [OpenAI connector reference](/reference/project-files/connectors#openai).

### OpenAI-compatible APIs

The connector can also target APIs that implement the OpenAI chat completions protocol. Configure the provider's URL and model on the connector. If the provider supports JSON mode but not OpenAI's JSON Schema response format, set `structured_output_mode` to `json_object`:

```yaml
type: connector
driver: openai
api_key: "{{ .env.PROVIDER_API_KEY }}"
base_url: https://llm.example.com/v1
model: example-model
structured_output_mode: json_object
```

For provider-specific request extensions, use the advanced `extra_body` map. Its values must be JSON-serializable, and it cannot override core request fields such as `model`, `messages`, `tools`, `response_format`, or streaming controls. For example, an OpenAI-compatible endpoint can receive a chat-template option as follows:

```yaml
extra_body:
chat_template_kwargs:
enable_thinking: false
```

These options belong to the connector, so different connectors in the same Rill process can use different provider behavior.
Comment on lines +51 to +72

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

structured_output_mode and extra_body are not declared in the OpenAI block of runtime/parser/schema/project.schema.yaml, which this PR does not touch. That block generates docs/docs/reference/project-files/connectors.md through cli/cmd/docs/generate_project.go:29,488 (make docs.generate), so the OpenAI connector reference this page links to for "additional configuration options" will not list either property, and runtime/ai/instructions/instructions.go:198 feeds the same schema to the AI assistant, which will keep reporting that these keys do not exist on an openai connector. Nothing fails at runtime: runtime/parser/parse_connector.go:16 captures connector properties with mapstructure:",remain" and never validates them against the schema. main has since reworked that block, so the two properties (and ideally the extra_body example) need to go on top of its current version.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added structured_output_mode and extra_body to the OpenAI block, with an example for a compatible provider, and regenerated connectors.md. I compared the OpenAI block on main and on this branch and it is the same, so this goes on top of the current version.


## Deploy to Rill Cloud

Rill requires you to explicitly provide an OpenAI API key to use the OpenAI connector. See the [connector reference](/reference/project-files/connectors#openai) for details.
Expand Down
21 changes: 21 additions & 0 deletions docs/docs/reference/project-files/connectors.md
Original file line number Diff line number Diff line change
Expand Up @@ -744,6 +744,14 @@ _[string]_ - The type of OpenAI API to use

_[string]_ - The version of the OpenAI API to use (e.g., '2023-05-15'). Required when API Type is AZURE or AZURE_AD

### `structured_output_mode`

_[string]_ - How output schemas are requested: json_schema (default) or json_object for compatible providers that do not support JSON Schema

### `extra_body`

_[object]_ - Advanced map of provider-specific JSON fields added to chat completion requests. Core request and response-shape fields (e.g., 'model', 'messages', 'tools', 'response_format') cannot be overridden

```yaml
# Example: OpenAI connector configuration
type: connector # Must be `connector` (required)
Expand All @@ -757,6 +765,19 @@ api_type: "openai" # The type of OpenAI API to use
api_version: "2023-05-15" # The version of the OpenAI API to use (e.g., '2023-05-15'). Required when API Type is AZURE or AZURE_AD
```

```yaml
# Example: OpenAI-compatible provider configuration
type: connector # Must be `connector` (required)
driver: openai # Must be `openai` _(required)_
api_key: "{{ .env.PROVIDER_API_KEY }}" # API key for the provider
base_url: "https://llm.example.com/v1" # The provider's OpenAI-compatible endpoint
model: "example-model" # The provider's model name
structured_output_mode: "json_object" # Use JSON mode when the provider does not support JSON Schema
extra_body: # Provider-specific fields added to chat completion requests
chat_template_kwargs:
enable_thinking: false
```

## Claude

### `driver`
Expand Down
47 changes: 29 additions & 18 deletions runtime/connection_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ package runtime

import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"time"

Expand Down Expand Up @@ -35,6 +36,7 @@ type cachedConnectionConfig struct {
config map[string]any
provision bool
provisionArgs map[string]any
key string // Set by getConnection, which is the only caller of Acquire
}

// newConnectionCache returns a concurrency-safe cache for open connections.
Expand All @@ -54,7 +56,7 @@ func (r *Runtime) newConnectionCache() conncache.Cache {
},
KeyFunc: func(cfg any) string {
x := cfg.(cachedConnectionConfig)
return generateKey(x)
return x.key
},
HangingFunc: func(cfg any, open bool) {
x := cfg.(cachedConnectionConfig)
Expand All @@ -74,6 +76,12 @@ func (r *Runtime) newConnectionCache() conncache.Cache {
// getConnection returns a cached connection for the given driver configuration.
// If instanceID is empty, the connection is considered shared (see drivers.Open for details).
func (r *Runtime) getConnection(ctx context.Context, cfg cachedConnectionConfig) (drivers.Handle, func(), error) {
key, err := generateKey(cfg)
if err != nil {
return nil, nil, err
}
cfg.key = key

handle, release, err := r.connCache.Acquire(ctx, cfg)
if err != nil {
return nil, nil, err
Expand Down Expand Up @@ -192,32 +200,35 @@ func (r *Runtime) openAndMigrate(ctx context.Context, cfg cachedConnectionConfig
return handle, nil
}

func generateKey(cfg cachedConnectionConfig) string {
func generateKey(cfg cachedConnectionConfig) (string, error) {
sb := strings.Builder{}
sb.WriteString(cfg.instanceID) // Empty if cfg.shared
sb.WriteString(":")
sb.WriteString(cfg.name)
sb.WriteString(":")
sb.WriteString(cfg.driver)
sb.WriteString(":")
keys := maps.Keys(cfg.config)
slices.Sort(keys)
for _, key := range keys {
sb.WriteString(key)
sb.WriteString(":")
sb.WriteString(fmt.Sprint(cfg.config[key]))
sb.WriteString(" ")
if err := writeConfigHash(&sb, cfg.config); err != nil {
return "", fmt.Errorf("connector %q: invalid config: %w", cfg.name, err)
}
if cfg.provision {
sb.WriteString(":provision=true:")
keys := maps.Keys(cfg.provisionArgs)
slices.Sort(keys)
for _, key := range keys {
sb.WriteString(key)
sb.WriteString(":")
sb.WriteString(fmt.Sprint(cfg.provisionArgs[key]))
sb.WriteString(" ")
if err := writeConfigHash(&sb, cfg.provisionArgs); err != nil {
return "", fmt.Errorf("connector %q: invalid provision args: %w", cfg.name, err)
}
}
return sb.String()
return sb.String(), nil
}

// writeConfigHash adds a deterministic, type-preserving identity for a connector configuration without embedding
// credentials in the cache key. JSON is canonical for the JSON-shaped connector maps produced by the parser (map
// keys are sorted by encoding/json, and strings/maps/slices remain distinct).
func writeConfigHash(sb *strings.Builder, config map[string]any) error {
canonical, err := json.Marshal(config)
if err != nil {
return err
}
sum := sha256.Sum256(canonical)
fmt.Fprintf(sb, "%x", sum)
return nil
}
65 changes: 65 additions & 0 deletions runtime/connection_cache_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package runtime

import (
"math"
"testing"

"github.com/stretchr/testify/require"
)

func TestGenerateConnectionKeyPreservesNestedJSONTypes(t *testing.T) {
base := cachedConnectionConfig{instanceID: "instance", name: "connector", driver: "openai"}

stringConfig := base
stringConfig.config = map[string]any{"extra_body": map[string]any{"extension": "[END]"}}
sliceConfig := base
sliceConfig.config = map[string]any{"extra_body": map[string]any{"extension": []any{"END"}}}
require.NotEqual(t, mustGenerateKey(t, stringConfig), mustGenerateKey(t, sliceConfig),
"a string and a JSON array must never reuse one connector handle")

mapLikeString := base
mapLikeString.config = map[string]any{"extra_body": "map[a:b]"}
nestedMap := base
nestedMap.config = map[string]any{"extra_body": map[string]any{"a": "b"}}
require.NotEqual(t, mustGenerateKey(t, mapLikeString), mustGenerateKey(t, nestedMap),
"a string and a JSON object must never reuse one connector handle")
}

func TestGenerateConnectionKeyIsCanonicalAndDoesNotExposeSecrets(t *testing.T) {
left := cachedConnectionConfig{
instanceID: "instance", name: "connector", driver: "openai",
config: map[string]any{
"api_key": "super-secret",
"extra_body": map[string]any{"thinking": map[string]any{"type": "disabled"}, "seed": float64(1)},
},
}
right := cachedConnectionConfig{
instanceID: "instance", name: "connector", driver: "openai",
config: map[string]any{
"extra_body": map[string]any{"seed": float64(1), "thinking": map[string]any{"type": "disabled"}},
"api_key": "super-secret",
},
}

leftKey := mustGenerateKey(t, left)
require.Equal(t, leftKey, mustGenerateKey(t, right), "map insertion order must not change connector identity")
require.NotContains(t, leftKey, "super-secret", "cache keys must not embed credentials")
}

func TestGetConnectionRejectsConfigThatCannotBeKeyed(t *testing.T) {
cfg := cachedConnectionConfig{
instanceID: "instance", name: "connector", driver: "openai",
config: map[string]any{"temperature": math.NaN()},
}

// The error must surface before the connection cache is used (it is nil here).
_, _, err := (&Runtime{}).getConnection(t.Context(), cfg)
require.ErrorContains(t, err, `connector "connector"`)
}

func mustGenerateKey(t *testing.T, cfg cachedConnectionConfig) string {
t.Helper()
key, err := generateKey(cfg)
require.NoError(t, err)
return key
}
Loading
Loading