Skip to content
Merged
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
15 changes: 15 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,20 @@ ARK_CHAT_MODELS=ep-primary,ep-backup
# ARK_CHAT_MODEL=ep-xxxxxxxx
ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3

# Optional CozeLoop tracing and approved prompt policy.
# Trace export and remote prompt failure never make customer conversations fail.
NIVORA_COZELOOP_ENABLED=false
NIVORA_COZELOOP_PROMPT_KEY=nivora.support.policy
# Pin an approved version in production. Leave empty only in controlled staging.
NIVORA_COZELOOP_PROMPT_VERSION=
NIVORA_COZELOOP_PROMPT_REFRESH=5m
NIVORA_COZELOOP_PROMPT_TIMEOUT=3s
COZELOOP_WORKSPACE_ID=your-workspace-id
# PAT is acceptable for development. Prefer JWT OAuth credentials in production.
COZELOOP_API_TOKEN=your-api-token
# COZELOOP_JWT_OAUTH_CLIENT_ID=your-client-id
# COZELOOP_JWT_OAUTH_PRIVATE_KEY=your-private-key
# COZELOOP_JWT_OAUTH_PUBLIC_KEY_ID=your-public-key-id

NIVORA_VERSION=dev
NIVORA_COMMIT=unknown
54 changes: 52 additions & 2 deletions cmd/nivora/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"context"
"encoding/json"
"log/slog"
"net/http"
"os"
Expand All @@ -15,7 +16,10 @@ import (
"github.com/Nesoriel/nivora/internal/agent"
"github.com/Nesoriel/nivora/internal/config"
"github.com/Nesoriel/nivora/internal/model/failover"
"github.com/Nesoriel/nivora/internal/promptpolicy"
providerhttp "github.com/Nesoriel/nivora/internal/provider/httpclient"
"github.com/Nesoriel/nivora/internal/requestctx"
looptrace "github.com/Nesoriel/nivora/internal/runtrace/cozeloop"
"github.com/Nesoriel/nivora/internal/telemetry"
"github.com/Nesoriel/nivora/internal/transport/httpserver"
)
Expand All @@ -28,6 +32,29 @@ func main() {
os.Exit(1)
}

loopRuntime, loopErr := looptrace.New(cfg.CozeLoopEnabled, logger)
if loopErr != nil {
logger.Warn("CozeLoop initialization failed; tracing and remote prompts are disabled", "error", loopErr)
loopRuntime = looptrace.Disabled(logger)
}

var policy promptpolicy.Source = promptpolicy.Static("", "bundled-v1", "bundled")
if loopRuntime.Client() != nil && cfg.CozeLoopPromptKey != "" {
remotePolicy, promptErr := promptpolicy.Remote(context.Background(), loopRuntime.Client(), promptpolicy.RemoteConfig{
Key: cfg.CozeLoopPromptKey,
Version: cfg.CozeLoopPromptVersion,
RefreshInterval: cfg.CozeLoopPromptRefresh,
RequestTimeout: cfg.CozeLoopPromptTimeout,
Fallback: policy,
Logger: logger,
})
if promptErr != nil {
logger.Warn("create CozeLoop prompt policy source", "error", promptErr)
} else {
policy = remotePolicy
}
}

providerClient, err := providerhttp.New(
cfg.ProviderBaseURL,
cfg.ProviderSharedSecret,
Expand Down Expand Up @@ -65,7 +92,13 @@ func main() {
os.Exit(1)
}
}
runtime, err = agent.New(runtimeModel, providerClient)
runtime, err = agent.New(
runtimeModel,
providerClient,
agent.WithPolicySource(policy),
agent.WithTracer(loopRuntime.Tracer()),
agent.WithBuildInfo(cfg.Version, cfg.Commit),
)
if err != nil {
logger.Error("create agent runtime", "error", err)
os.Exit(1)
Expand All @@ -77,9 +110,24 @@ func main() {

metrics := telemetry.New()
transport := httpserver.New(cfg, runtime, providerClient, metrics, logger)
root := http.NewServeMux()
root.HandleFunc("GET /version", func(w http.ResponseWriter, _ *http.Request) {
snapshot := policy.Current()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]any{
"version": cfg.Version,
"commit": cfg.Commit,
"prompt_key": cfg.CozeLoopPromptKey,
"prompt_version": snapshot.Version,
"prompt_source": snapshot.Source,
"cozeloop_enabled": cfg.CozeLoopEnabled && loopRuntime.Client() != nil,
})
})
root.Handle("/", requestctx.Middleware(transport.Handler()))

server := &http.Server{
Addr: cfg.Address,
Handler: transport.Handler(),
Handler: root,
ReadHeaderTimeout: 5 * time.Second,
IdleTimeout: 120 * time.Second,
}
Expand All @@ -102,5 +150,7 @@ func main() {
logger.Error("graceful shutdown failed", "error", err)
os.Exit(1)
}
policy.Close()
loopRuntime.Close(ctx)
logger.Info("Nivora stopped")
}
79 changes: 79 additions & 0 deletions docs/cozeloop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# CozeLoop production integration

Nivora integrates CozeLoop through the official Eino callback adapter and the official CozeLoop Go SDK. The integration is optional and fail-open for customer traffic: tracing or PromptHub outages must not make customer conversations fail.

## Enable tracing

```env
NIVORA_COZELOOP_ENABLED=true
COZELOOP_WORKSPACE_ID=your-workspace-id
```

For local development, an API token can be used:

```env
COZELOOP_API_TOKEN=your-api-token
```

Production deployments should use JWT OAuth credentials instead of a long-lived PAT:

```env
COZELOOP_JWT_OAUTH_CLIENT_ID=your-client-id
COZELOOP_JWT_OAUTH_PRIVATE_KEY=your-private-key
COZELOOP_JWT_OAUTH_PUBLIC_KEY_ID=your-public-key-id
```

## Trace redaction

The Eino callback parser uses an allowlist. It may export:

- model provider and endpoint/model name
- input, output, and total token counts
- first-response latency
- stream flag
- Prompt key, version, and provider
- Tool call ID

It deliberately removes:

- customer questions and conversation history
- model answers and reasoning content
- Tool inputs and Tool outputs
- bearer contexts and service secrets
- raw Provider payloads
- product-internal prompts, recipes, and hidden metadata

The root Nivora run span contains only support-safe identifiers and release metadata: request ID, conversation ID, tenant ID, Nivora version/commit, active Prompt version/source, authentication state, scope count, and Tool count.

## Approved Prompt policy

Nivora's mandatory safety rules are compiled into the binary. CozeLoop PromptHub may provide only an additional approved system-policy appendix.

```env
NIVORA_COZELOOP_PROMPT_KEY=nivora.support.policy
NIVORA_COZELOOP_PROMPT_VERSION=v1.2.0
NIVORA_COZELOOP_PROMPT_REFRESH=5m
NIVORA_COZELOOP_PROMPT_TIMEOUT=3s
```

Production should pin an approved Prompt version. An empty version means "latest" and should be limited to controlled staging.

A remote Prompt is accepted only when it contains at least one non-empty system-role message and the combined appendix is at most 32 KiB. User-role-only Prompts are rejected.

Failure behavior:

1. Keep the most recent successfully fetched Prompt in memory.
2. If no remote Prompt has ever succeeded, use the bundled policy.
3. Never stop serving because PromptHub is unavailable.
4. Never allow a remote appendix to weaken the compiled safety rules.

## Diagnostics

`GET /version` reports:

- Nivora version and commit
- configured Prompt key
- active Prompt version and source
- whether CozeLoop initialized successfully

No credential or raw Prompt content is returned.
18 changes: 14 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,39 +4,49 @@ go 1.23.0

require (
github.com/cloudwego/eino v0.9.12
github.com/cloudwego/eino-ext/callbacks/cozeloop v0.3.1
github.com/cloudwego/eino-ext/components/model/ark v0.1.68
github.com/coze-dev/cozeloop-go v0.1.22
github.com/coze-dev/cozeloop-go/spec v0.1.8
)

require (
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/bluele/gcache v0.0.2 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/eino-contrib/jsonschema v1.0.3 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/goph/emperror v0.17.2 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/nikolalohinski/gonja v1.5.3 // indirect
github.com/nikolalohinski/gonja/v2 v2.3.1 // indirect
github.com/pelletier/go-toml/v2 v2.0.9 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pkg/errors v0.9.2-0.20201214064552-5dd12d0cfe7f // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/volcengine/volc-sdk-golang v1.0.23 // indirect
github.com/volcengine/volcengine-go-sdk v1.2.27 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/yargevad/filepathx v1.0.0 // indirect
golang.org/x/arch v0.11.0 // indirect
golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect
golang.org/x/sync v0.15.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.26.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/yaml.v2 v2.2.8 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
Expand Down
Loading
Loading