diff --git a/.dockerignore b/.dockerignore index 4a9b99d..363f56a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,5 @@ .dockerignore +.env* compose.yaml Dockerfile ngbot diff --git a/.env.example b/.env.example index 0b9ec5c..59d7d40 100644 --- a/.env.example +++ b/.env.example @@ -26,11 +26,12 @@ NGBOT_BUILD_DATE=unknown # Compose build metadata; set to an RFC 3339 ## LLM settings NG_LLM_GEMINI_API_KEY= # Gemini API key; required for reactor when NG_LLM_API_TYPE=gemini +NG_LLM_OPENROUTER_API_KEY= # OpenRouter key; required when NG_LLM_API_TYPE=openrouter NG_LLM_OPENAI_API_KEY= # OpenAI API key; required for reactor when NG_LLM_API_TYPE=openai NG_LLM_API_KEY= # Legacy fallback for the selected provider when its dedicated key is empty NG_LLM_API_MODEL= # Optional model override; empty uses the provider default NG_LLM_API_URL=https://api.openai.com/v1 # LLM API base URL (used when NG_LLM_API_TYPE=openai) -NG_LLM_API_TYPE=openai # LLM provider {openai,gemini}; Gemini implicit caching is automatic +NG_LLM_API_TYPE=openai # LLM provider {openai,gemini,openrouter}; Gemini implicit caching is automatic NG_LLM_REQUEST_TIMEOUT=45s # Classification request deadline ## Spam control settings diff --git a/AGENTS.md b/AGENTS.md index 9a2b468..240a58d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,6 +91,7 @@ For detailed architecture, see [docs/CODEBASE_MAP.md](docs/CODEBASE_MAP.md). ### External Services - Telegram Bot API. +- **OpenRouter Classification**: The OpenRouter adapter uses `deepseek/deepseek-v4.1-flash` with request-level `provider.only=["deepseek"]`, disabled fallbacks and required parameter support. Preserve the shared public-comment spam policy, critical bot `start`/`startapp` handling, and valid `0`/`1`-only completed responses. Usage/generation telemetry must exclude message and reasoning texts and credentials; missing cache/cost fields are unknown, not measured zero. Keep native OpenAI/Gemini adapters available. - LLM APIs (OpenAI-compatible and Gemini). - Banlist API (lols.bot). - **Join-Captcha WebApp** πŸ”’: The gatekeeper join-captcha WebApp server speaks plain HTTP and **MUST** run behind a TLS-terminating reverse proxy. Its listen address is configured via `GatekeeperWebApp.ListenAddr`. In the Docker deployment it binds `0.0.0.0:8080` inside the container (mapped to `127.0.0.1:18080` on the host); the default **must NOT** be changed to loopback or the container port mapping breaks. diff --git a/README.md b/README.md index 82e4200..6ac4bc7 100644 --- a/README.md +++ b/README.md @@ -113,11 +113,12 @@ See [.env.example](.env.example) for a quick reference. `NGBOT_*` variables conf | | `NG_GATEKEEPER_WEBAPP_MAX_CONCURRENT` | Maximum in-flight Mini App requests | `32` | Integer greater than zero; `0` is invalid | | | `NG_GATEKEEPER_WEBAPP_REQUESTS_PER_MINUTE` | Per-client Mini App request limit | `120` | Integer greater than zero; `0` is invalid | | | `NG_LLM_GEMINI_API_KEY` | Gemini credential; required when `reactor` uses Gemini | | Preferred over the legacy key | +| | `NG_LLM_OPENROUTER_API_KEY` | OpenRouter credential; required when using OpenRouter | | Preferred over the legacy key | | | `NG_LLM_OPENAI_API_KEY` | OpenAI credential; required when `reactor` uses OpenAI | | Preferred over the legacy key | | | `NG_LLM_API_KEY` | Legacy credential fallback for the selected provider | | Used only when its dedicated key is empty | -| | `NG_LLM_API_MODEL` | Optional LLM model override | Provider-specific | Any valid OpenAI or Gemini model | +| | `NG_LLM_API_MODEL` | Optional LLM model override | Provider-specific | OpenAI/Gemini overrides; OpenRouter requires `deepseek/deepseek-v4.1-flash` | | | `NG_LLM_API_URL` | OpenAI-compatible API base URL | `https://api.openai.com/v1` | Used when `NG_LLM_API_TYPE=openai` | -| | `NG_LLM_API_TYPE` | LLM provider | `openai` | `openai`, `gemini` | +| | `NG_LLM_API_TYPE` | LLM provider | `openai` | `openai`, `gemini`, `openrouter` | | | `NG_LLM_REQUEST_TIMEOUT` | Maximum duration of one classification request | `45s` | Any positive duration string | | | `NG_SPAM_LOG_CHANNEL_USERNAME` | Channel for spam logging | | Any valid channel username | | | `NG_SPAM_DEBUG_USER_ID` | User allowed to run diagnostics in private chat | `0` | Telegram user ID | @@ -225,3 +226,25 @@ Feel free to add feature requests in issues. Structured debug logs include `author_kind`, `author_id`, chat/message IDs, `trust_phase`, skip reason and classification outcome without message text. Existing provider usage logs retain token counts. Daily SQLite KV statistics use `stats:::` keys: `author_check_initial`, `author_check_renewal`, `author_check_edit`, `author_check_pending_case`, `author_check_report`, `author_trust_granted`, and `author_trust_skipped`. Check counters include attempts; `llm_checked` counts successful message/report classifications, and existing `spam_confirmed` / `false_positive` counters record resolved cases. Compare equivalent traffic windows after rollout; local tests do not establish the cause or savings of a production incident. Context cleanup runs through the existing periodic bounded retention job. Checked-message bindings contain only identity and timing metadata, and remain until the chat is removed. They survive trust expiry, renewal, resets and context deletion so that an old checked message cannot lose edit protection during a later trust period. + +## Public comment classification and OpenRouter accounting + +The initial check and reported-message check share one policy. Public-channel subscribers do not have to join the discussion group. Spam requires promotional/referral redirection or an invitation to unspecified income/work. News sources, documentation, ordinary social profiles, detailed vacancies (including iGaming recruitment), contextual recommendations, criticism and jokes remain allowed. Telegram bot `start`/`startapp` payloads require scrutiny: unexplained personal or opaque referral-like codes are spam, while clearly contextual document/function links and technical examples are allowed. Quoting a scam to discuss or warn about it is allowed. Existing author trust, banlists, CAPTCHA, voting and per-chat settings remain separate. + +Select `NG_LLM_API_TYPE=openrouter`, set `NG_LLM_OPENROUTER_API_KEY`, and leave the model empty or set `deepseek/deepseek-v4.1-flash`. Each request permits only the official `deepseek` provider, disables provider fallbacks and requires parameter support. The OpenRouter endpoint is fixed to `https://openrouter.ai/api/v1`; the OpenAI base URL is unrelated. Requests use low reasoning effort with a 2,048-token completion budget, including reasoning. Truncated, refused, malformed or unexpected-provider responses cannot become spam verdicts. DeepSeek implicit prefix caching needs no cache-management calls. Stable policy and global examples precede changing context and candidate data. + +At log level `4` (info) or higher, `OpenRouter usage metadata` records normalized/native token counts, cached tokens, cache writes when reported, reasoning, cost and cost breakdowns, generation/request/upstream IDs, actual provider, finish reasons and timings. Numeric usage fields are preserved even when the SDK has no typed field. A background queue of at most 32 metadata lookups retries `/generation` for up to ten minutes, because OpenRouter publishes these records after a delay. Separate generation records join the initial usage record by generation ID; shutdown cancels pending lookups. Unavailable statistics do not invalidate a valid verdict. No prompt, completion text, reasoning text, credentials or external-user identity is logged. Missing cache/cost observations are distinguished from measured zeros. Cumulative ratios cover completed requests since process start; raw records remain subject to Compose log rotation (five 10 MiB files). + +Aggregate any retained time window without adding costs from usage and generation twice: + +```sh +docker compose logs --no-log-prefix --since 24h ngbot | ./scripts/openrouter-stats.py +``` + +`cache_hit_request_rate` measures requests with any reported hit among cache-observed requests; `cached_input_token_rate` measures cached input tokens among cache-observed input tokens. Reasoning tokens are part of completion tokens and must not be added again. Reported cache discounts and actual billed costs are separate; no assumed list-price savings are substituted for observed billing. Generation IDs deduplicate repeated log records. Export metadata logs before rotation for longer reporting windows. + +The opt-in semantic regression uses the real pinned provider and incurs API charges: + +```sh +NGBOT_RUN_LIVE_OPENROUTER_MODERATION=1 go test ./internal/handlers/moderation -run '^TestLiveOpenRouterPublicComments$' -count=1 -v +``` diff --git a/cmd/ngbot/main.go b/cmd/ngbot/main.go index e7d83e8..24433c1 100644 --- a/cmd/ngbot/main.go +++ b/cmd/ngbot/main.go @@ -20,6 +20,7 @@ import ( "github.com/iamwavecut/ngbot/internal/adapters" "github.com/iamwavecut/ngbot/internal/adapters/llm/gemini" "github.com/iamwavecut/ngbot/internal/adapters/llm/openai" + "github.com/iamwavecut/ngbot/internal/adapters/llm/openrouter" "github.com/iamwavecut/ngbot/internal/bot" "github.com/iamwavecut/ngbot/internal/config" "github.com/iamwavecut/ngbot/internal/db" @@ -203,6 +204,7 @@ func main() { config.RegisterSecret(cfg.LLM.APIKey) config.RegisterSecret(cfg.LLM.GeminiAPIKey) config.RegisterSecret(cfg.LLM.OpenAIAPIKey) + config.RegisterSecret(cfg.LLM.OpenRouterAPIKey) log.SetFormatter(&config.NbFormatter{}) log.SetOutput(os.Stdout) @@ -456,7 +458,12 @@ func buildRuntime(ctx context.Context, cfg *config.Config, errChan chan<- shutdo errChan, ) + var llmComponent lifecycle.Component + if component, ok := llmAPI.(lifecycle.Component); ok { + llmComponent = component + } runtime := lifecycle.NewRuntime( + llmComponent, service, banService, spamControl, @@ -546,12 +553,15 @@ func maskConfiguration(cfg *config.Config) *config.Config { maskedConfig.LLM.APIKey = maskSecret(cfg.LLM.APIKey) maskedConfig.LLM.GeminiAPIKey = maskSecret(cfg.LLM.GeminiAPIKey) maskedConfig.LLM.OpenAIAPIKey = maskSecret(cfg.LLM.OpenAIAPIKey) + maskedConfig.LLM.OpenRouterAPIKey = maskSecret(cfg.LLM.OpenRouterAPIKey) return &maskedConfig } func configureLLM(cfg *config.Config, logger *log.Entry) (adapters.LLM, error) { apiKey := cfg.LLM.APIKeyForProvider() switch cfg.LLM.Type { + case config.LLMProviderOpenRouter: + return openrouter.NewOpenRouter(apiKey, cfg.LLM.Model, logger) case config.LLMProviderOpenAI: return openai.NewOpenAI( apiKey, diff --git a/cmd/ngbot/main_test.go b/cmd/ngbot/main_test.go index a9a956a..28aae70 100644 --- a/cmd/ngbot/main_test.go +++ b/cmd/ngbot/main_test.go @@ -19,6 +19,7 @@ import ( api "github.com/OvyFlash/telegram-bot-api" "github.com/iamwavecut/ngbot/internal/adapters/llm/gemini" "github.com/iamwavecut/ngbot/internal/adapters/llm/openai" + "github.com/iamwavecut/ngbot/internal/adapters/llm/openrouter" "github.com/iamwavecut/ngbot/internal/bot" "github.com/iamwavecut/ngbot/internal/config" "github.com/iamwavecut/ngbot/internal/db" @@ -166,9 +167,10 @@ func TestMaskConfigurationRedactsCredentialsCompletely(t *testing.T) { cfg := &config.Config{ TelegramAPIToken: "telegram-prefix-secret-suffix", LLM: config.LLM{ - APIKey: "llm-prefix-secret-suffix", - GeminiAPIKey: "gemini-prefix-secret-suffix", - OpenAIAPIKey: "openai-prefix-secret-suffix", + APIKey: "llm-prefix-secret-suffix", + GeminiAPIKey: "gemini-prefix-secret-suffix", + OpenAIAPIKey: "openai-prefix-secret-suffix", + OpenRouterAPIKey: "router-prefix-secret-suffix", }, } @@ -185,6 +187,9 @@ func TestMaskConfigurationRedactsCredentialsCompletely(t *testing.T) { if masked.LLM.OpenAIAPIKey != redactedConfigurationValue { t.Fatalf("OpenAI key = %q", masked.LLM.OpenAIAPIKey) } + if masked.LLM.OpenRouterAPIKey != redactedConfigurationValue { + t.Fatal("OpenRouter key was not redacted") + } } func TestConfigureLLMUsesSelectedProviderCredential(t *testing.T) { @@ -200,6 +205,11 @@ func TestConfigureLLMUsesSelectedProviderCredential(t *testing.T) { llm: config.LLM{Type: "gemini", GeminiAPIKey: "gemini-key"}, want: (*gemini.API)(nil), }, + { + name: "OpenRouter", + llm: config.LLM{Type: "openrouter", OpenRouterAPIKey: "router-key"}, + want: (*openrouter.API)(nil), + }, { name: "OpenAI", llm: config.LLM{Type: "openai", OpenAIAPIKey: "openai-key", BaseURL: "https://api.openai.com/v1"}, diff --git a/compose.yaml b/compose.yaml index 9b4ebee..8368c21 100644 --- a/compose.yaml +++ b/compose.yaml @@ -28,6 +28,7 @@ services: NG_GATEKEEPER_WEBAPP_REQUESTS_PER_MINUTE: ${NG_GATEKEEPER_WEBAPP_REQUESTS_PER_MINUTE:-120} NG_LLM_API_KEY: ${NG_LLM_API_KEY:-} NG_LLM_GEMINI_API_KEY: ${NG_LLM_GEMINI_API_KEY:-} + NG_LLM_OPENROUTER_API_KEY: ${NG_LLM_OPENROUTER_API_KEY:-} NG_LLM_OPENAI_API_KEY: ${NG_LLM_OPENAI_API_KEY:-} NG_LLM_API_URL: ${NG_LLM_API_URL:-https://api.openai.com/v1} NG_LLM_API_MODEL: ${NG_LLM_API_MODEL:-} diff --git a/go.mod b/go.mod index e69bc32..6a27d68 100644 --- a/go.mod +++ b/go.mod @@ -3,17 +3,18 @@ module github.com/iamwavecut/ngbot go 1.26.8 require ( - github.com/OvyFlash/telegram-bot-api v0.0.0-20260715235732-aca8bf3898bb + github.com/OvyFlash/telegram-bot-api v0.0.0-20260826195316-83c4eada97a1 + github.com/iamwavecut/gopenrouter v0.0.0-20260930193135-78d3d1821ee6 github.com/jmoiron/sqlx v1.4.0 github.com/mitchellh/go-homedir v1.1.0 github.com/pborman/uuid v1.2.1 github.com/pkg/errors v0.9.1 github.com/rubenv/sql-migrate v1.8.1 - github.com/sirupsen/logrus v1.9.4 - golang.org/x/sync v0.22.0 - google.golang.org/genai v1.66.0 + github.com/sirupsen/logrus v1.10.2 + golang.org/x/sync v0.23.0 + google.golang.org/genai v1.72.0 gopkg.in/yaml.v2 v2.4.0 - modernc.org/sqlite v1.55.0 + modernc.org/sqlite v1.60.1 ) require ( @@ -40,6 +41,7 @@ require ( github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/MirrexOne/unqueryvet v1.5.4 // indirect github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect + github.com/OpenRouterTeam/go-sdk v0.9.22 // indirect github.com/alecthomas/chroma/v2 v2.24.1 // indirect github.com/alecthomas/go-check-sumtype v0.3.1 // indirect github.com/alexkohler/nakedret/v2 v2.0.6 // indirect @@ -74,7 +76,6 @@ require ( github.com/curioswitch/go-reassign v0.3.0 // indirect github.com/daixiang0/gci v0.13.7 // indirect github.com/dave/dst v0.27.3 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/denis-tingaikin/go-header v0.5.0 // indirect github.com/dlclark/regexp2 v1.12.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect @@ -170,7 +171,6 @@ require ( github.com/nunnatsa/ginkgolinter v0.23.0 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.3.1 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.12.1 // indirect github.com/prometheus/client_model v0.2.0 // indirect github.com/prometheus/common v0.32.1 // indirect @@ -201,10 +201,11 @@ require ( github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/viper v1.12.0 // indirect + github.com/spyzhov/ajson v0.8.0 // indirect github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect - github.com/stretchr/objx v0.5.2 // indirect - github.com/stretchr/testify v1.11.1 // indirect + github.com/stretchr/objx v0.5.3 // indirect + github.com/stretchr/testify v1.12.1 // indirect github.com/subosito/gotenv v1.4.1 // indirect github.com/tetafro/godot v1.5.6 // indirect github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4 // indirect @@ -232,14 +233,14 @@ require ( go.opentelemetry.io/otel/trace v1.44.0 // indirect go.uber.org/multierr v1.10.0 // indirect go.uber.org/zap v1.27.0 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.56.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/crypto v0.57.0 // indirect golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect - golang.org/x/mod v0.40.0 // indirect - golang.org/x/net v0.58.0 // indirect - golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 // indirect - golang.org/x/text v0.41.0 // indirect - golang.org/x/tools v0.49.0 // indirect + golang.org/x/mod v0.41.0 // indirect + golang.org/x/net v0.59.0 // indirect + golang.org/x/telemetry v0.0.0-20260908163034-4bcc4b2ee518 // indirect + golang.org/x/text v0.42.0 // indirect + golang.org/x/tools v0.50.0 // indirect golang.org/x/vuln v1.6.0 // indirect google.golang.org/api v0.291.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0 // indirect @@ -248,9 +249,9 @@ require ( gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect honnef.co/go/tools v0.7.0 // indirect - modernc.org/libc v1.74.4 // indirect + modernc.org/libc v1.77.1 // indirect modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.11.0 // indirect + modernc.org/memory v1.12.1 // indirect mvdan.cc/gofumpt v0.10.0 // indirect mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 // indirect ) @@ -258,10 +259,10 @@ require ( require ( github.com/go-gorp/gorp/v3 v3.1.0 // indirect github.com/iamwavecut/tool v1.4.2 - github.com/sashabaranov/go-openai v1.41.2 + github.com/sashabaranov/go-openai v1.43.0 github.com/sethvargo/go-envconfig v1.4.3 golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 // indirect - golang.org/x/sys v0.47.0 // indirect + golang.org/x/sys v0.48.0 // indirect ) tool ( diff --git a/go.sum b/go.sum index d7bbbd4..f2777b3 100644 --- a/go.sum +++ b/go.sum @@ -81,8 +81,10 @@ github.com/MirrexOne/unqueryvet v1.5.4 h1:38QOxShO7JmMWT+eCdDMbcUgGCOeJphVkzzRgy github.com/MirrexOne/unqueryvet v1.5.4/go.mod h1:fs9Zq6eh1LRIhsDIsxf9PONVUjYdFHdtkHIgZdJnyPU= github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4= github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo= -github.com/OvyFlash/telegram-bot-api v0.0.0-20260715235732-aca8bf3898bb h1:MvKVOrwsx25AOMkMTGilUTqjFJ4S/OR3nyNq1PsEwTE= -github.com/OvyFlash/telegram-bot-api v0.0.0-20260715235732-aca8bf3898bb/go.mod h1:2nRUdsKyWhvezqW/rBGWEQdcTQeTtnbSNd2dgx76WYA= +github.com/OpenRouterTeam/go-sdk v0.9.22 h1:MW/USsw4WhrPqu5glOB5hs3iYtHIxzwW98ByRxWcC14= +github.com/OpenRouterTeam/go-sdk v0.9.22/go.mod h1:8vFtUZ9I2YiDntYbck42wQhrx9DsJN5PHbViaHwTkXc= +github.com/OvyFlash/telegram-bot-api v0.0.0-20260826195316-83c4eada97a1 h1:mqNSQIrF/wfAj4yJfeVWJPXqk+XqMwDYB1zHbVfltj0= +github.com/OvyFlash/telegram-bot-api v0.0.0-20260826195316-83c4eada97a1/go.mod h1:2nRUdsKyWhvezqW/rBGWEQdcTQeTtnbSNd2dgx76WYA= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM= @@ -340,8 +342,8 @@ github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hf github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= -github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= @@ -386,6 +388,8 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/iamwavecut/gopenrouter v0.0.0-20260930193135-78d3d1821ee6 h1:Tmbq8O4fLrL26+vbYrG4RN1h3HkAYbTb1E2bknbROec= +github.com/iamwavecut/gopenrouter v0.0.0-20260930193135-78d3d1821ee6/go.mod h1:Z2GLkqCga5Gz4gdx6DgH3CiqJWiMgc6knW9BS3ZdeY4= github.com/iamwavecut/tool v1.4.2 h1:9CFa8j/csTmWfcGpRqThnr+2K0tGjBDluspQOwUmEJs= github.com/iamwavecut/tool v1.4.2/go.mod h1:tQXQCDMDf8QXh27cGLRTU7T9vNfC1M+tv4JDNdUzhX0= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= @@ -525,8 +529,6 @@ github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY= github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= @@ -583,8 +585,8 @@ github.com/sanposhiho/wastedassign/v2 v2.1.0 h1:crurBF7fJKIORrV85u9UUpePDYGWnwvv github.com/sanposhiho/wastedassign/v2 v2.1.0/go.mod h1:+oSmSC+9bQ+VUAxA66nBb0Z7N8CK7mscKTDYC6aIek4= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= -github.com/sashabaranov/go-openai v1.41.2 h1:vfPRBZNMpnqu8ELsclWcAvF19lDNgh1t6TVfFFOPiSM= -github.com/sashabaranov/go-openai v1.41.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= +github.com/sashabaranov/go-openai v1.43.0 h1:HNRpO8TAQ01ssO7aPXO/68QRlcCCYQQ5GfHbFceRZcY= +github.com/sashabaranov/go-openai v1.43.0/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= github.com/sashamelentyev/interfacebloat v1.1.0 h1:xdRdJp0irL086OyW1H/RTZTr1h/tMEOsumirXcOJqAw= github.com/sashamelentyev/interfacebloat v1.1.0/go.mod h1:+Y9yU5YdTkrNvoX0xHc84dxiN1iBi9+G8zZIhPVoNjQ= github.com/sashamelentyev/usestdlibvars v1.29.0 h1:8J0MoRrw4/NAXtjQqTHrbW9NN+3iMf7Knkq057v4XOQ= @@ -598,8 +600,8 @@ github.com/sethvargo/go-envconfig v1.4.3/go.mod h1:ebe6rgj7KzrRZPzDXU4W6WZWDEirQ github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= -github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= -github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo= +github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q= github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+Wwfd0XE= github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4= github.com/sonatard/noctx v0.5.1 h1:wklWg9c9ZYugOAk7qG4yP4PBrlQsmSLPTvW1K4PRQMs= @@ -620,19 +622,21 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.12.0 h1:CZ7eSOd3kZoaYDLbXnmzgQI5RlciuXBMA+18HwHRfZQ= github.com/spf13/viper v1.12.0/go.mod h1:b6COn30jlNxbm/V2IqWiNWkJ+vZNiMNksliPCiuKtSI= +github.com/spyzhov/ajson v0.8.0 h1:sFXyMbi4Y/BKjrsfkUZHSjA2JM1184enheSjjoT/zCc= +github.com/spyzhov/ajson v0.8.0/go.mod h1:63V+CGM6f1Bu/p4nLIN8885ojBdt88TbLoSFzyqMuVA= github.com/ssgreg/nlreturn/v2 v2.2.1 h1:X4XDI7jstt3ySqGU86YGAURbxw3oTDPK9sPEi6YEwQ0= github.com/ssgreg/nlreturn/v2 v2.2.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I= github.com/stbenjam/no-sprintf-host-port v0.3.1 h1:AyX7+dxI4IdLBPtDbsGAyqiTSLpCP9hWRrXQDU4Cm/g= github.com/stbenjam/no-sprintf-host-port v0.3.1/go.mod h1:ODbZesTCHMVKthBHskvUUexdcNHAQRXk9NpSsL8p/HQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs= github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= github.com/tenntenn/modver v1.0.1 h1:2klLppGhDgzJrScMpkj9Ujy3rXPUspSjAcev9tSEBgA= @@ -710,8 +714,9 @@ go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -719,8 +724,8 @@ golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= -golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -761,8 +766,8 @@ golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs= -golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -797,8 +802,8 @@ golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= -golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -818,8 +823,8 @@ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -864,10 +869,10 @@ golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 h1:ZUSxONxc981v7AW7QUg+I9WwZzSTTJ019ENBYr5pV/Q= -golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5/go.mod h1:LVehoXe41cL5SCVQilsV7Gg6BNG+Js6P9PhSbYTIUkQ= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/telemetry v0.0.0-20260908163034-4bcc4b2ee518 h1:F5BWKvW126NXR74uxkxuc1jQHhm/rwm/J3rSiFyuRs4= +golang.org/x/telemetry v0.0.0-20260908163034-4bcc4b2ee518/go.mod h1:i+ivNqjDnTF3WTElsdk5g9V5DTSBYgdNo7xTU9SDwYA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -879,8 +884,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= -golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -931,8 +936,8 @@ golang.org/x/tools v0.1.1-0.20210302220138-2ac05c832e1a/go.mod h1:9bzcO0MWcOuT0t golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= -golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= +golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU= +golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= @@ -969,8 +974,8 @@ google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7 google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/genai v1.66.0 h1:njWPPscy3l6rqBnYhFz3YCeLOVvZrzteTVTYOhxxcZc= -google.golang.org/genai v1.66.0/go.mod h1:mDdPDFXo1Ats7f1WXVyZgWb/CkMzFWTWJruIMy7hGIU= +google.golang.org/genai v1.72.0 h1:sn3V1cHkHQhMcjtUrO2y1r54jprbFjmeokBU1IKfpZk= +google.golang.org/genai v1.72.0/go.mod h1:2j40fGpqPPIZNjaDKmjGGjYwCJi6Nm2ofmyP65GnqtY= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= @@ -1061,30 +1066,30 @@ honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9 honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU= honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc= -modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= -modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= -modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= -modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg= +modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE= +modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= -modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= -modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= +modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs= +modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= -modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM= -modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= +modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw= +modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= diff --git a/internal/adapters/llm/openrouter/openrouter.go b/internal/adapters/llm/openrouter/openrouter.go new file mode 100644 index 0000000..9e132f5 --- /dev/null +++ b/internal/adapters/llm/openrouter/openrouter.go @@ -0,0 +1,366 @@ +package openrouter + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" + + router "github.com/iamwavecut/gopenrouter" + "github.com/iamwavecut/gopenrouter/shared" + "github.com/iamwavecut/ngbot/internal/adapters" + "github.com/iamwavecut/ngbot/internal/adapters/llm" + log "github.com/sirupsen/logrus" +) + +const ( + DefaultModel = "deepseek/deepseek-v4.1-flash" + officialProvider = "deepseek" + maxOutputTokens = 2048 + statisticsTimeout = 10 * time.Minute + finishStop = "stop" + generationIDField = "generation_id" +) + +type API struct { + client *router.Client + model string + logger *log.Entry + mu sync.Mutex + totals usageTotals + statsCtx context.Context + statsCancel context.CancelFunc + statsWorkers sync.WaitGroup + statsSlots chan struct{} + statsClosed bool + statsRetryDelays []time.Duration +} + +type usageTotals struct { + Requests int + UsageRequests int + CacheObservedRequests int + CacheHitRequests int + PromptTokens int + CacheObservedPromptTokens int + CachedTokens int + CompletionTokens int + ReasoningTokens int + CostObservedRequests int + CostUSD float64 +} + +type responseMetadata struct { + Usage map[string]any + Generation map[string]any + Provider string +} + +type metadataKey struct{} + +type metadataTransport struct{ base http.RoundTripper } + +func (t metadataTransport) RoundTrip(request *http.Request) (*http.Response, error) { + response, err := t.base.RoundTrip(request) + if err != nil { + return nil, err + } + metadata, ok := request.Context().Value(metadataKey{}).(*responseMetadata) + if !ok || response.StatusCode != http.StatusOK { + return response, nil + } + body, err := io.ReadAll(io.LimitReader(response.Body, 16<<20)) + closeErr := response.Body.Close() + if err == nil { + err = closeErr + } + if err != nil { + return nil, err + } + if len(body) == 16<<20 { + return nil, fmt.Errorf("OpenRouter response exceeds metadata limit") + } + response.Body = io.NopCloser(bytes.NewReader(body)) + var document map[string]any + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + if decoder.Decode(&document) == nil { + if strings.HasSuffix(request.URL.Path, "/chat/completions") { + metadata.Usage, _ = document["usage"].(map[string]any) + metadata.Provider, _ = document["provider"].(string) + } else if strings.HasSuffix(request.URL.Path, "/generation") { + data, _ := document["data"].(map[string]any) + metadata.Generation = generationMetadata(data) + } + } + return response, nil +} + +func NewOpenRouter(apiKey, model string, logger *log.Entry) (adapters.LLM, error) { + return newOpenRouter(apiKey, model, "", logger) +} + +func newOpenRouter(apiKey, model, baseURL string, logger *log.Entry) (*API, error) { + if strings.TrimSpace(apiKey) == "" { + return nil, fmt.Errorf("openrouter API key is empty") + } + if model == "" { + model = DefaultModel + } + if model != DefaultModel { + return nil, fmt.Errorf("openrouter moderation requires model %s", DefaultModel) + } + if logger == nil { + logger = log.New().WithField("adapter", "openrouter") + } + cfg := router.DefaultConfig(apiKey) + if baseURL != "" { + cfg.BaseURL = baseURL + } + cfg.SiteName = "ngbot" + cfg.HTTPClient = &http.Client{Transport: metadataTransport{base: http.DefaultTransport}} + statsCtx, statsCancel := context.WithCancel(context.Background()) + return &API{ + statsCtx: statsCtx, statsCancel: statsCancel, statsSlots: make(chan struct{}, 32), + statsRetryDelays: []time.Duration{0, 5 * time.Second, 15 * time.Second, 30 * time.Second, time.Minute, 2 * time.Minute, 4 * time.Minute}, + client: router.NewClientWithConfig(cfg), model: model, + logger: logger.WithFields(log.Fields{"provider": "openrouter", "model": model, "provider_pin": officialProvider}), + }, nil +} + +func (a *API) ChatCompletion(ctx context.Context, messages []llm.ChatCompletionMessage) (llm.ChatCompletionResponse, error) { + if len(messages) == 0 { + return llm.ChatCompletionResponse{}, fmt.Errorf("chat completion requires at least one message") + } + converted := make([]router.ChatCompletionMessage, 0, len(messages)) + seenConversation := false + for _, message := range messages { + role := message.Role + switch role { + case llm.RoleSystem: + if seenConversation { + return llm.ChatCompletionResponse{}, fmt.Errorf("system message must precede conversation contents") + } + case llm.RoleUser, llm.RoleAssistant, "": + seenConversation = true + if role == "" { + role = llm.RoleUser + } + default: + return llm.ChatCompletionResponse{}, fmt.Errorf("unsupported message role: %s", role) + } + converted = append(converted, router.ChatCompletionMessage{Role: router.ChatCompletionMessageRole(role), Content: message.Content}) + } + metadata := &responseMetadata{} + ctx = context.WithValue(ctx, metadataKey{}, metadata) + started := time.Now() + response, err := a.client.CreateChatCompletion(ctx, router.ChatCompletionRequest{ + Model: a.model, Messages: converted, MaxTokens: maxOutputTokens, TopP: 1, + Reasoning: &router.ReasoningParams{Effort: router.ReasoningEffortLow, Exclude: true}, + Provider: &shared.ProviderPreferences{Only: []string{officialProvider}, AllowFallbacks: new(false), RequireParameters: new(true)}, + ExtraBody: map[string]any{"temperature": 0}, + }) + elapsed := time.Since(started) + if err != nil { + a.logger.WithFields(log.Fields{"duration_ms": elapsed.Milliseconds(), "failure_kind": llm.FailureKindOf(err)}).Warn("OpenRouter request failed") + return llm.ChatCompletionResponse{}, llm.NewFailure(llm.FailureKindOf(err), err) + } + a.collectStatistics(response, metadata, elapsed) + a.scheduleGenerationStatistics(response.ID) + if metadata.Provider != "" && !strings.EqualFold(metadata.Provider, officialProvider) { + return llm.ChatCompletionResponse{}, llm.NewFailure(llm.FailureProvider, fmt.Errorf("OpenRouter returned an unexpected provider")) + } + if len(response.Choices) != 1 { + return llm.ChatCompletionResponse{}, llm.NewFailure(llm.FailureMalformedOutput, fmt.Errorf("OpenRouter returned no unique classification")) + } + choice := response.Choices[0] + if choice.FinishReason == "content_filter" || choice.Message.Refusal != "" { + return llm.ChatCompletionResponse{}, llm.NewFailure(llm.FailurePolicyBlocked, fmt.Errorf("OpenRouter classification was blocked")) + } + if choice.FinishReason != finishStop || (strings.TrimSpace(choice.Message.Content) != "0" && strings.TrimSpace(choice.Message.Content) != "1") { + return llm.ChatCompletionResponse{}, llm.NewFailure(llm.FailureMalformedOutput, fmt.Errorf("OpenRouter classification was incomplete or malformed")) + } + return llm.ChatCompletionResponse{Choices: []llm.ChatCompletionChoice{{Message: llm.ChatCompletionMessage{Role: llm.RoleAssistant, Content: choice.Message.Content}}}}, nil +} + +func (a *API) collectStatistics(response *router.ChatCompletionResponse, metadata *responseMetadata, elapsed time.Duration) { + fields := log.Fields{ + generationIDField: response.ID, "response_model": response.Model, + "served_provider": metadata.Provider, "duration_ms": elapsed.Milliseconds(), "usage": numericMetadata(metadata.Usage), + } + if len(response.Choices) > 0 { + fields["finish_reason"] = response.Choices[0].FinishReason + fields["native_finish_reason"] = response.Choices[0].NativeFinishReason + } + a.mu.Lock() + a.totals.Requests++ + if metadata.Usage != nil { + a.totals.UsageRequests++ + prompt := int(number(metadata.Usage["prompt_tokens"])) + a.totals.PromptTokens += prompt + a.totals.CompletionTokens += int(number(metadata.Usage["completion_tokens"])) + if cost, ok := metadata.Usage["cost"].(json.Number); ok { + a.totals.CostObservedRequests++ + a.totals.CostUSD += number(cost) + } + if details, ok := metadata.Usage["prompt_tokens_details"].(map[string]any); ok && details["cached_tokens"] != nil { + cached := int(number(details["cached_tokens"])) + a.totals.CacheObservedRequests++ + a.totals.CacheObservedPromptTokens += prompt + a.totals.CachedTokens += cached + if cached > 0 { + a.totals.CacheHitRequests++ + } + fields["uncached_prompt_tokens"] = max(0, prompt-cached) + if prompt > 0 { + fields["cached_prompt_fraction"] = float64(cached) / float64(prompt) + } + } + if details, ok := metadata.Usage["completion_tokens_details"].(map[string]any); ok { + a.totals.ReasoningTokens += int(number(details["reasoning_tokens"])) + } + } + totals := a.totals + a.mu.Unlock() + fields["totals"] = totals + if totals.CacheObservedRequests > 0 { + fields["cache_hit_request_rate"] = float64(totals.CacheHitRequests) / float64(totals.CacheObservedRequests) + } + if totals.CacheObservedPromptTokens > 0 { + fields["cached_input_token_rate"] = float64(totals.CachedTokens) / float64(totals.CacheObservedPromptTokens) + } + a.logger.WithFields(fields).Info("OpenRouter usage metadata") +} + +func number(value any) float64 { + if value, ok := value.(json.Number); ok { + number, _ := value.Float64() + return number + } + return 0 +} + +func numericMetadata(input map[string]any) map[string]any { + if input == nil { + return nil + } + output := make(map[string]any, len(input)) + for key, value := range input { + switch value := value.(type) { + case json.Number, bool, nil: + output[key] = value + case map[string]any: + output[key] = numericMetadata(value) + } + } + return output +} + +func generationMetadata(input map[string]any) map[string]any { + if input == nil { + return nil + } + output := numericMetadata(input) + for _, key := range []string{"external_user", "user_agent", "origin", "http_referer", "workspace_id", "app_id", "preset_id", "session_id"} { + delete(output, key) + } + for _, key := range []string{"id", "upstream_id", "request_id", "created_at", "model", "provider_name", "finish_reason", "native_finish_reason", "api_type", "router", "service_tier", "data_region"} { + if value, ok := input[key].(string); ok { + output[key] = value + } + } + if responses, ok := input["provider_responses"].([]any); ok { + items := make([]map[string]any, 0, len(responses)) + for _, response := range responses { + if response, ok := response.(map[string]any); ok { + items = append(items, generationMetadata(response)) + } + } + output["provider_responses"] = items + } + return output +} + +func (a *API) scheduleGenerationStatistics(id string) { + if id == "" { + return + } + a.mu.Lock() + if a.statsClosed { + a.mu.Unlock() + return + } + select { + case a.statsSlots <- struct{}{}: + a.statsWorkers.Add(1) + default: + a.mu.Unlock() + a.logger.WithField(generationIDField, id).Warn("OpenRouter generation statistics queue full") + return + } + baseCtx := a.statsCtx + retryDelays := a.statsRetryDelays + a.mu.Unlock() + go func() { + defer a.statsWorkers.Done() + defer func() { <-a.statsSlots }() + metadata := &responseMetadata{} + ctx, cancel := context.WithTimeout(context.WithValue(baseCtx, metadataKey{}, metadata), statisticsTimeout) + defer cancel() + var lastErr error + for _, delay := range retryDelays { + if delay > 0 { + timer := time.NewTimer(delay) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-timer.C: + } + } + attemptCtx, attemptCancel := context.WithTimeout(ctx, 10*time.Second) + _, lastErr = a.client.GetGeneration(attemptCtx, id) + attemptCancel() + if lastErr == nil { + if provider, ok := metadata.Generation["provider_name"].(string); ok && !strings.EqualFold(provider, officialProvider) { + a.logger.WithField(generationIDField, id).Error("OpenRouter generation used an unexpected provider") + } + a.logger.WithFields(log.Fields{generationIDField: id, "generation": metadata.Generation}).Info("OpenRouter generation metadata") + return + } + if ctx.Err() != nil { + return + } + } + a.logger.WithFields(log.Fields{generationIDField: id, "failure_kind": llm.FailureKindOf(lastErr)}).Warn("OpenRouter generation statistics unavailable") + }() +} + +func (a *API) Close() error { + a.mu.Lock() + if !a.statsClosed { + a.statsClosed = true + a.statsCancel() + } + a.mu.Unlock() + a.statsWorkers.Wait() + return nil +} + +func (a *API) Start(ctx context.Context) error { + a.mu.Lock() + defer a.mu.Unlock() + if a.statsClosed { + return fmt.Errorf("OpenRouter adapter is closed") + } + a.statsCancel() + a.statsCtx, a.statsCancel = context.WithCancel(ctx) + return nil +} + +func (a *API) Stop(_ context.Context) error { return a.Close() } diff --git a/internal/adapters/llm/openrouter/openrouter_test.go b/internal/adapters/llm/openrouter/openrouter_test.go new file mode 100644 index 0000000..cdf0b0a --- /dev/null +++ b/internal/adapters/llm/openrouter/openrouter_test.go @@ -0,0 +1,326 @@ +package openrouter + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/iamwavecut/ngbot/internal/adapters/llm" + log "github.com/sirupsen/logrus" +) + +const ( + testProvider = "DeepSeek" + generationPath = "/generation" + testCandidate = "candidate" +) + +func TestPinnedRequestAndCompletePrivateUsageMetadata(t *testing.T) { + t.Parallel() + var logs bytes.Buffer + logger := log.New() + logger.SetOutput(&logs) + logger.SetFormatter(&log.JSONFormatter{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Header.Get("Authorization") != "Bearer test-key" { + t.Error("missing authentication") + } + switch r.URL.Path { + case "/chat/completions": + var request map[string]any + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Error(err) + return + } + provider := request["provider"].(map[string]any) + if provider["allow_fallbacks"] != false || provider["require_parameters"] != true || provider["only"].([]any)[0] != officialProvider { + t.Errorf("routing = %#v", provider) + } + if request["model"] != DefaultModel || request["temperature"] != float64(0) || request["max_tokens"] != float64(maxOutputTokens) { + t.Errorf("request configuration = %#v", request) + } + reasoning := request["reasoning"].(map[string]any) + if reasoning["effort"] != "low" || reasoning["exclude"] != true { + t.Errorf("reasoning = %#v", reasoning) + } + if strings.Contains(string(mustJSON(t, request)), "cache_control") { + t.Error("unexpected explicit cache management") + } + _, _ = w.Write([]byte(`{"id":"gen-test","model":"deepseek/deepseek-v4.1-flash","provider":"DeepSeek","choices":[{"finish_reason":"stop","native_finish_reason":"stop","message":{"role":"assistant","content":"0","reasoning":"private-reasoning"}}],"usage":{"prompt_tokens":1000,"completion_tokens":81,"total_tokens":1081,"cost":0.001,"prompt_tokens_details":{"cached_tokens":800,"cache_write_tokens":0},"completion_tokens_details":{"reasoning_tokens":80},"cost_details":{"upstream_inference_prompt_cost":0.0004,"upstream_inference_completions_cost":0.0006},"unexpected_string":"private-usage"}}`)) + case generationPath: + if r.URL.Query().Get("id") != "gen-test" { + t.Error("missing generation ID") + } + _, _ = w.Write([]byte(`{"data":{"id":"gen-test","provider_name":"DeepSeek","native_tokens_prompt":1000,"native_tokens_cached":800,"native_tokens_reasoning":80,"cache_discount":0.0002,"total_cost":0.001,"latency":300,"generation_time":400,"request_id":"req-test","external_user":"private-user","user_agent":"private-agent","provider_responses":[{"provider_name":"DeepSeek","status":200,"latency":300}]}}`)) + default: + t.Errorf("unexpected path %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(server.Close) + adapter, err := newOpenRouter("test-key", "", server.URL, log.NewEntry(logger)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = adapter.Close() }) + for range 2 { + response, err := adapter.ChatCompletion(t.Context(), []llm.ChatCompletionMessage{{Role: llm.RoleSystem, Content: "policy", Cacheable: true}, {Role: llm.RoleUser, Content: "private-candidate"}}) + if err != nil || len(response.Choices) != 1 || response.Choices[0].Message.Content != "0" { + t.Fatalf("response=%#v error=%v", response, err) + } + } + adapter.statsWorkers.Wait() + output := logs.String() + for _, secret := range []string{"private-candidate", "private-reasoning", "private-user", "private-agent", "private-usage", "test-key"} { + if strings.Contains(output, secret) { + t.Errorf("metadata leaked %s", secret) + } + } + for _, field := range []string{"cached_tokens", "cache_write_tokens", "upstream_inference_prompt_cost", "native_tokens_reasoning", "request_id", "provider_responses", "cache_hit_request_rate", "cached_input_token_rate"} { + if !strings.Contains(output, field) { + t.Errorf("missing %s", field) + } + } + if adapter.totals.Requests != 2 || adapter.totals.CachedTokens != 1600 || adapter.totals.CacheHitRequests != 2 || adapter.totals.ReasoningTokens != 160 { + t.Fatalf("totals=%#v", adapter.totals) + } +} + +func TestClassificationFailuresAndStatisticsFailureIsolation(t *testing.T) { + t.Parallel() + for _, tt := range []struct { + name, finish, content, provider string + want llm.FailureKind + }{ + {"truncation", "length", "1", testProvider, llm.FailureMalformedOutput}, + {"reasoning only", finishStop, "", testProvider, llm.FailureMalformedOutput}, + {"unexpected format", finishStop, "yes", testProvider, llm.FailureMalformedOutput}, + {"content blocked", "content_filter", "", testProvider, llm.FailurePolicyBlocked}, + {"wrong provider", finishStop, "0", "Other", llm.FailureProvider}, + {"metadata temporarily unavailable", finishStop, "0", testProvider, ""}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == generationPath { + w.WriteHeader(http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"id": "gen-test", "provider": tt.provider, "choices": []any{map[string]any{"finish_reason": tt.finish, "message": map[string]any{"role": "assistant", "content": tt.content}}}}) + })) + t.Cleanup(server.Close) + adapter, err := newOpenRouter("test-key", "", server.URL, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = adapter.Close() }) + _, err = adapter.ChatCompletion(t.Context(), []llm.ChatCompletionMessage{{Role: llm.RoleUser, Content: testCandidate}}) + if tt.want == "" { + if err != nil { + t.Fatal(err) + } + } else if err == nil || llm.FailureKindOf(err) != tt.want { + t.Fatalf("error=%v want=%s", err, tt.want) + } + if adapter.totals.CacheObservedRequests != 0 { + t.Fatal("missing cache stats interpreted as zero hit") + } + }) + } +} + +func TestInvalidConfigurationAndMessagesNeverSendRequests(t *testing.T) { + t.Parallel() + if _, err := NewOpenRouter("", "", nil); err == nil { + t.Fatal("accepted empty key") + } + if _, err := NewOpenRouter("key", "other/model", nil); err == nil { + t.Fatal("accepted unpinned model") + } + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { requests++ })) + t.Cleanup(server.Close) + adapter, err := newOpenRouter("key", "", server.URL, nil) + if err != nil { + t.Fatal(err) + } + for _, messages := range [][]llm.ChatCompletionMessage{nil, {{Role: "tool"}}, {{Role: llm.RoleUser}, {Role: llm.RoleSystem}}} { + if _, err := adapter.ChatCompletion(t.Context(), messages); err == nil { + t.Fatal("accepted invalid messages") + } + } + if requests != 0 { + t.Fatalf("invalid input sent %d requests", requests) + } +} + +func TestRequestTimeoutIsTyped(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case <-r.Context().Done(): + case <-time.After(time.Second): + } + })) + t.Cleanup(server.Close) + adapter, err := newOpenRouter("key", "", server.URL, nil) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond) + defer cancel() + _, err = adapter.ChatCompletion(ctx, []llm.ChatCompletionMessage{{Role: llm.RoleUser, Content: testCandidate}}) + if err == nil || llm.FailureKindOf(err) != llm.FailureTimeout { + t.Fatalf("timeout error=%v", err) + } +} + +func mustJSON(t *testing.T, value any) []byte { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return data +} + +func TestShutdownCancelsPendingGenerationLookups(t *testing.T) { + t.Parallel() + entered := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == generationPath { + close(entered) + select { + case <-r.Context().Done(): + case <-time.After(time.Second): + } + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"gen-shutdown","provider":"DeepSeek","choices":[{"finish_reason":"stop","message":{"role":"assistant","content":"0"}}]}`)) + })) + t.Cleanup(server.Close) + adapter, err := newOpenRouter("key", "", server.URL, nil) + if err != nil { + t.Fatal(err) + } + if err := adapter.Start(t.Context()); err != nil { + t.Fatal(err) + } + _, err = adapter.ChatCompletion(t.Context(), []llm.ChatCompletionMessage{{Role: llm.RoleUser, Content: testCandidate}}) + if err != nil { + t.Fatal(err) + } + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("no background lookup") + } + if err := adapter.Stop(t.Context()); err != nil { + t.Fatal(err) + } + if len(adapter.statsSlots) != 0 { + t.Fatal("pending lookup survived shutdown") + } + if err := adapter.Start(t.Context()); err == nil { + t.Fatal("closed adapter restarted") + } +} + +func TestLiveOpenRouterAccounting(t *testing.T) { + if os.Getenv("NGBOT_RUN_LIVE_OPENROUTER_ACCOUNTING") != "1" { + t.Skip("live accounting check is disabled") + } + logger := log.New() + logger.SetFormatter(&log.JSONFormatter{}) + adapter, err := newOpenRouter(os.Getenv("NG_LLM_OPENROUTER_API_KEY"), "", "", log.NewEntry(logger)) + if err != nil { + t.Fatal(err) + } + defer func() { _ = adapter.Close() }() + messages := []llm.ChatCompletionMessage{{Role: llm.RoleSystem, Content: strings.Repeat("Classify ordinary conversation as 0 and advertising as 1. ", 500) + "Reply exactly 0 or 1."}, {Role: llm.RoleUser, Content: "Thank you, that is clear."}} + for range 3 { + response, err := adapter.ChatCompletion(t.Context(), messages) + if err != nil || len(response.Choices) != 1 || strings.TrimSpace(response.Choices[0].Message.Content) != "0" { + t.Fatalf("live classification failed: %v", err) + } + } + adapter.statsWorkers.Wait() +} + +func TestDelayedGenerationStatisticsDoNotDelayClassification(t *testing.T) { + t.Parallel() + var logs bytes.Buffer + logger := log.New() + logger.SetOutput(&logs) + logger.SetFormatter(&log.JSONFormatter{}) + attempts := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == generationPath { + attempts++ + if attempts == 1 { + w.WriteHeader(http.StatusNotFound) + return + } + _, _ = w.Write([]byte(`{"data":{"id":"gen-delayed","provider_name":"DeepSeek","total_cost":0.001,"native_tokens_cached":800}}`)) + return + } + _, _ = w.Write([]byte(`{"id":"gen-delayed","provider":"DeepSeek","choices":[{"finish_reason":"stop","message":{"role":"assistant","content":"0"}}]}`)) + })) + t.Cleanup(server.Close) + adapter, err := newOpenRouter("key", "", server.URL, log.NewEntry(logger)) + if err != nil { + t.Fatal(err) + } + adapter.statsRetryDelays = []time.Duration{0, 150 * time.Millisecond} + t.Cleanup(func() { _ = adapter.Close() }) + started := time.Now() + _, err = adapter.ChatCompletion(t.Context(), []llm.ChatCompletionMessage{{Role: llm.RoleUser, Content: testCandidate}}) + if err != nil { + t.Fatal(err) + } + if time.Since(started) >= 150*time.Millisecond { + t.Fatal("metadata retry delayed classification") + } + adapter.statsWorkers.Wait() + if attempts != 2 || !strings.Contains(logs.String(), "OpenRouter generation metadata") { + t.Fatal("delayed metadata was not collected") + } +} + +func TestLiveOpenRouterGenerationMetadata(t *testing.T) { + ids := strings.FieldsFunc(os.Getenv("NGBOT_LIVE_GENERATION_IDS"), func(r rune) bool { return r == ',' }) + if len(ids) == 0 { + t.Skip("provide existing generation IDs for the live metadata check") + } + var logs bytes.Buffer + logger := log.New() + logger.SetFormatter(&log.JSONFormatter{}) + logger.SetOutput(&logs) + adapter, err := newOpenRouter(os.Getenv("NG_LLM_OPENROUTER_API_KEY"), "", "", log.NewEntry(logger)) + if err != nil { + t.Fatal(err) + } + defer func() { _ = adapter.Close() }() + for index, id := range ids { + adapter.scheduleGenerationStatistics(id) + if (index+1)%32 == 0 { + adapter.statsWorkers.Wait() + } + } + adapter.statsWorkers.Wait() + if strings.Count(logs.String(), "OpenRouter generation metadata") != len(ids) { + t.Fatal("not all generation records were retrieved") + } + fmt.Print(logs.String()) +} diff --git a/internal/config/config.go b/internal/config/config.go index 690f2a5..99f2f92 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -45,13 +45,14 @@ type ( } LLM struct { - APIKey string `env:"LLM_API_KEY"` - GeminiAPIKey string `env:"LLM_GEMINI_API_KEY"` - OpenAIAPIKey string `env:"LLM_OPENAI_API_KEY"` - Model string `env:"LLM_API_MODEL"` - BaseURL string `env:"LLM_API_URL,default=https://api.openai.com/v1"` - Type string `env:"LLM_API_TYPE,default=openai"` - RequestTimeout time.Duration `env:"LLM_REQUEST_TIMEOUT,default=45s"` + APIKey string `env:"LLM_API_KEY"` + GeminiAPIKey string `env:"LLM_GEMINI_API_KEY"` + OpenRouterAPIKey string `env:"LLM_OPENROUTER_API_KEY"` + OpenAIAPIKey string `env:"LLM_OPENAI_API_KEY"` + Model string `env:"LLM_API_MODEL"` + BaseURL string `env:"LLM_API_URL,default=https://api.openai.com/v1"` + Type string `env:"LLM_API_TYPE,default=openai"` + RequestTimeout time.Duration `env:"LLM_REQUEST_TIMEOUT,default=45s"` } SpamControl struct { @@ -73,6 +74,7 @@ type ( const ( LLMProviderGemini = "gemini" LLMProviderOpenAI = "openai" + LLMProviderOpenRouter = "openrouter" gatekeeperActionLeaseDuration = 2 * time.Minute ) @@ -162,6 +164,7 @@ func normalizeLLMConfig(cfg *LLM) { cfg.APIKey = strings.TrimSpace(cfg.APIKey) cfg.GeminiAPIKey = strings.TrimSpace(cfg.GeminiAPIKey) cfg.OpenAIAPIKey = strings.TrimSpace(cfg.OpenAIAPIKey) + cfg.OpenRouterAPIKey = strings.TrimSpace(cfg.OpenRouterAPIKey) cfg.Model = strings.TrimSpace(cfg.Model) cfg.BaseURL = strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/") cfg.Type = strings.ToLower(strings.TrimSpace(cfg.Type)) @@ -178,7 +181,7 @@ func validateLLMConfig(cfg LLM) error { return fmt.Errorf("%s LLM API key is empty", cfg.Type) } switch cfg.Type { - case LLMProviderGemini: + case LLMProviderGemini, LLMProviderOpenRouter: return nil case LLMProviderOpenAI: endpoint, err := url.Parse(cfg.BaseURL) @@ -200,6 +203,10 @@ func (cfg LLM) APIKeyForProvider() string { if cfg.GeminiAPIKey != "" { return cfg.GeminiAPIKey } + case LLMProviderOpenRouter: + if cfg.OpenRouterAPIKey != "" { + return cfg.OpenRouterAPIKey + } case LLMProviderOpenAI: if cfg.OpenAIAPIKey != "" { return cfg.OpenAIAPIKey diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5b56da6..234b7b9 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -10,6 +10,7 @@ const ( testGatekeeperHandler = "gatekeeper" testGeminiAPIKey = "gemini-key" testOpenAIAPIKey = "openai-key" + testLegacyAPIKey = "legacy-key" ) func TestLoadUsesProviderSpecificCredential(t *testing.T) { @@ -268,7 +269,7 @@ func TestValidateConfig(t *testing.T) { cfg: Config{ EnabledHandlers: []string{"reactor"}, LLM: LLM{ - APIKey: "legacy-key", + APIKey: testLegacyAPIKey, Type: LLMProviderGemini, }, SpamControl: SpamControl{SafeMessagesRequired: 3, AuthorTrustDuration: 720 * time.Hour}, @@ -361,7 +362,7 @@ func TestValidateConfigRequiresOnlySelectedProviderCredential(t *testing.T) { }, { name: "legacy Gemini credential fallback", - llm: LLM{Type: LLMProviderGemini, APIKey: "legacy-key", RequestTimeout: 45 * time.Second}, + llm: LLM{Type: LLMProviderGemini, APIKey: testLegacyAPIKey, RequestTimeout: 45 * time.Second}, }, { name: "selected credential missing", @@ -466,3 +467,19 @@ func TestAuthorTrustConfigIgnoresLegacyDuration(t *testing.T) { } } } + +func TestOpenRouterCredentialSelectionAndValidation(t *testing.T) { + t.Parallel() + cfg := validConfigForLLM() + cfg.LLM = LLM{Type: LLMProviderOpenRouter, OpenRouterAPIKey: "router-key", APIKey: testLegacyAPIKey, RequestTimeout: time.Minute} + if err := validateConfig(&cfg); err != nil { + t.Fatal(err) + } + if cfg.LLM.APIKeyForProvider() != "router-key" { + t.Fatal("dedicated OpenRouter key was not selected") + } + cfg.LLM.OpenRouterAPIKey = "" + if cfg.LLM.APIKeyForProvider() != testLegacyAPIKey { + t.Fatal("legacy fallback was not preserved") + } +} diff --git a/internal/handlers/moderation/spam_detector.go b/internal/handlers/moderation/spam_detector.go index 8b3813c..d88bec7 100644 --- a/internal/handlers/moderation/spam_detector.go +++ b/internal/handlers/moderation/spam_detector.go @@ -291,69 +291,39 @@ func newClassificationExample(message string, response int) classificationExampl const spamDecisionBoundary = ` ΠŸΡ€Π°Π²ΠΈΠ»ΠΎ Ρ€Π΅ΡˆΠ΅Π½ΠΈΡ: -- ΠŸΡ€ΠΈΠΌΠ΅Ρ€Ρ‹ Π½ΠΈΠΆΠ΅ ΠΈΠ»Π»ΡŽΡΡ‚Ρ€ΠΈΡ€ΡƒΡŽΡ‚ Π³Ρ€Π°Π½ΠΈΡ†Ρ‹ ΠΏΡ€Π°Π²ΠΈΠ» ΠΈ Π½Π΅ ΡΠ²Π»ΡΡŽΡ‚ΡΡ голосованиСм: количСство ΠΏΡ€ΠΈΠΌΠ΅Ρ€ΠΎΠ² класса 1 Π½Π΅ ΠΏΠΎΠ²Ρ‹ΡˆΠ°Π΅Ρ‚ Π²Π΅Ρ€ΠΎΡΡ‚Π½ΠΎΡΡ‚ΡŒ спама. -- Π‘Ρ‚Π°Π²ΡŒ 1 Ρ‚ΠΎΠ»ΡŒΠΊΠΎ Ссли Π² сообщСнии Π΅ΡΡ‚ΡŒ хотя Π±Ρ‹ ΠΎΠ΄ΠΈΠ½ ΠΈΠ· пСрСчислСнных ΠΏΡ€ΠΈΠ·Π½Π°ΠΊΠΎΠ² спама. +- ΠŸΡ€ΠΈΠΌΠ΅Ρ€Ρ‹ ΠΈΠ»Π»ΡŽΡΡ‚Ρ€ΠΈΡ€ΡƒΡŽΡ‚ Π³Ρ€Π°Π½ΠΈΡ†Ρ‹ ΠΏΡ€Π°Π²ΠΈΠ» ΠΈ Π½Π΅ ΡΠ²Π»ΡΡŽΡ‚ΡΡ голосованиСм: количСство ΠΏΡ€ΠΈΠΌΠ΅Ρ€ΠΎΠ² класса 1 Π½Π΅ ΠΏΠΎΠ²Ρ‹ΡˆΠ°Π΅Ρ‚ Π²Π΅Ρ€ΠΎΡΡ‚Π½ΠΎΡΡ‚ΡŒ спама. ΠŸΡ€ΠΈ ΠΊΠΎΠ½Ρ„Π»ΠΈΠΊΡ‚Π΅ ΠΏΡ€ΠΈΠΌΠ΅Ρ€Π° с ΠΏΡ€Π°Π²ΠΈΠ»Π°ΠΌΠΈ примСняй ΠΏΡ€Π°Π²ΠΈΠ»Π°. +- Π‘Ρ‚Π°Π²ΡŒ 1 Ρ‚ΠΎΠ»ΡŒΠΊΠΎ ΠΏΡ€ΠΈ Π½Π°Π»ΠΈΡ‡ΠΈΠΈ ΡΠ°ΠΌΠΎΡΡ‚ΠΎΡΡ‚Π΅Π»ΡŒΠ½ΠΎΠ³ΠΎ ΠΏΡ€ΠΈΠ·Π½Π°ΠΊΠ° ΠΎΠ΄Π½ΠΎΠΉ ΠΈΠ· Π΄Π²ΡƒΡ… ΠΊΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΠΉ Π½ΠΈΠΆΠ΅. Π­Ρ‚ΠΎ ΠΏΡƒΠ±Π»ΠΈΡ‡Π½Ρ‹ΠΉ Ρ‡Π°Ρ‚ ΠΊΠΎΠΌΠΌΠ΅Π½Ρ‚Π°Ρ€ΠΈΠ΅Π²: подписчик ΠΊΠ°Π½Π°Π»Π° Π½Π΅ обязан ΡΠΎΡΡ‚ΠΎΡΡ‚ΡŒ Π² Π³Ρ€ΡƒΠΏΠΏΠ΅. ΠžΡ‚ΡΡƒΡ‚ΡΡ‚Π²ΠΈΠ΅ члСнства, истории, контСкста ΠΈΠ»ΠΈ ΠΏΡ€Π΅ΠΆΠ½ΠΈΡ… сообщСний Π½Π΅ ΡΠ²ΠΈΠ΄Π΅Ρ‚Π΅Π»ΡŒΡΡ‚Π²ΡƒΠ΅Ρ‚ ΠΎ спамС. + +1. Π Π΅ΠΊΠ»Π°ΠΌΠ½ΠΎΠ΅ ΠΏΠ΅Ρ€Π΅Π½Π°ΠΏΡ€Π°Π²Π»Π΅Π½ΠΈΠ΅, Ρ€Π΅Ρ„Π΅Ρ€Π°Π»ΠΊΠΈ ΠΈ ΠΏΡ€ΠΎΠ΄Π²ΠΈΠΆΠ΅Π½ΠΈΠ΅ опасных сСрвисов: +- ΠŸΡ€ΠΈΠ·Ρ‹Π² ΠΏΠ΅Ρ€Π΅ΠΉΡ‚ΠΈ, Π·Π°Ρ€Π΅Π³ΠΈΡΡ‚Ρ€ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒΡΡ, ΠΊΡƒΠΏΠΈΡ‚ΡŒ, ΠΏΠΎΠ΄ΠΏΠΈΡΠ°Ρ‚ΡŒΡΡ, ΠΏΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ бонус ΠΈΠ»ΠΈ Π²ΠΎΡΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚ΡŒΡΡ коммСрчСским сСрвисом со ссылкой ΠΈΠ»ΠΈ адрСсом назначСния являСтся спамом. Бюда относятся Ρ€Π΅ΠΊΠ»Π°ΠΌΠ° ΠΊΠ°Π·ΠΈΠ½ΠΎ, ставок, эротичСских сСрвисов, ΠΌΠΎΡˆΠ΅Π½Π½ΠΈΡ‡Π΅ΡΠΊΠΈΡ… сайтов, ΠΊΡ€ΠΈΠΏΡ‚ΠΎΠΎΠ±ΠΌΠ΅Π½Π½ΠΈΠΊΠΎΠ², финансовых схСм ΠΈ инструмСнтов Π΄Π΅Π°Π½ΠΎΠ½ΠΈΠΌΠΈΠ·Π°Ρ†ΠΈΠΈ/пробивания Π»ΠΈΡ‡Π½Ρ‹Ρ… Π΄Π°Π½Π½Ρ‹Ρ…. Π Π΅ΠΊΠ»Π°ΠΌΠ° пробивания с ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚ΠΎΠΌ вмСсто URL Ρ‚ΠΎΠΆΠ΅ являСтся спамом. +- РаспространСниС прямой ссылки Π½Π° ΠΎΡ‡Π΅Π²ΠΈΠ΄Π½ΠΎΠ΅ ΠΊΠ°Π·ΠΈΠ½ΠΎ, скамСрский сайт, ΠΊΡ€ΠΈΠΏΡ‚ΠΎΠΎΠ±ΠΌΠ΅Π½Π½ΠΈΠΊ, эротичСский ΠΈΠ»ΠΈ Π΄Π΅Π°Π½ΠΎΠ½ΠΈΠΌΠΈΠ·ΠΈΡ€ΡƒΡŽΡ‰ΠΈΠΉ сСрвис Π±Π΅Π· понятного ΠΈΠ½Ρ„ΠΎΡ€ΠΌΠ°Ρ†ΠΈΠΎΠ½Π½ΠΎΠ³ΠΎ/критичСского контСкста Ρ‚ΠΎΠΆΠ΅ являСтся спамом. +- Бсылки Π½Π° Π±ΠΎΡ‚ΠΎΠ² ΠΈ ΠΏΠ°Π±Π»ΠΈΠΊΠΈ с Π·Π°Π·Ρ‹Π²Π°ΡŽΡ‰ΠΈΠΌ тСкстом ΡΠ²Π»ΡΡŽΡ‚ΡΡ спамом, Π΄Π°ΠΆΠ΅ Ссли сСрвис бСсплатный. ΠžΠ±Ρ‹Ρ‡Π½Π°Ρ ΠΏΡ€ΠΎΡΡŒΠ±Π° Π½Π°ΠΏΠΈΡΠ°Ρ‚ΡŒ Π² Π»ΠΈΡ‡ΠΊΡƒ Π±Π΅Π· Ρ€Π΅ΠΊΠ»Π°ΠΌΡ‹ ΠΈΠ»ΠΈ абстрактного Π·Π°Ρ€Π°Π±ΠΎΡ‚ΠΊΠ° Π½Π΅ являСтся спамом. +- Π―Π²Π½Ρ‹ΠΉ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŒΡΠΊΠΈΠΉ Ρ€Π΅Ρ„Π΅Ρ€Π°Π»ΡŒΠ½Ρ‹ΠΉ ΠΈΠ»ΠΈ партнёрский ΠΊΠΎΠ΄ Π² ΠΏΠ°Ρ€Π°ΠΌΠ΅Ρ‚Ρ€Π°Ρ… ΠΈΠ»ΠΈ ΠΏΡƒΡ‚ΠΈ ссылки (ref, referral, affiliate, inviter, invite-code ΠΈ Π°Π½Π°Π»ΠΎΠ³ΠΈΡ‡Π½Ρ‹Π΅) являСтся ΠΏΡ€ΠΈΠ·Π½Π°ΠΊΠΎΠΌ спама ΠΏΡ€ΠΈ распространСнии этой ссылки, Π΄Π°ΠΆΠ΅ Π±Π΅Π· ΠΎΡ‚Π΄Π΅Π»ΡŒΠ½ΠΎΠ³ΠΎ ΠΏΡ€ΠΈΠ·Ρ‹Π²Π°. НСзнакомый Π΄ΠΎΠΌΠ΅Π½ Π½Π΅ освобоТдаСт Ρ‚Π°ΠΊΡƒΡŽ Ρ€Π΅Ρ„Π΅Ρ€Π°Π»ΠΊΡƒ ΠΎΡ‚ ΠΏΡ€ΠΎΠ²Π΅Ρ€ΠΊΠΈ. Π‘Π»ΠΎΠ²ΠΎ ref ΠΈΠ»ΠΈ invite само ΠΏΠΎ сСбС Π±Π΅Π· значСния Ρ€Π΅Ρ„Π΅Ρ€Π°Π»ΡŒΠ½ΠΎΠ³ΠΎ ΠΊΠΎΠ΄Π° Π½Π΅ Π΄ΠΎΠΊΠ°Π·Ρ‹Π²Π°Π΅Ρ‚ Ρ€Π΅Ρ„Π΅Ρ€Π°Π»ΠΊΡƒ. +- Бсылка Π½Π° Telegram-Π±ΠΎΡ‚Π° с нСпустым start ΠΈΠ»ΠΈ startapp Ρ‚Ρ€Π΅Π±ΡƒΠ΅Ρ‚ Π±ΠΎΠ»Π΅Π΅ ΠΊΡ€ΠΈΡ‚ΠΈΡ‡Π½ΠΎΠΉ ΠΎΡ†Π΅Π½ΠΊΠΈ: ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŒΡΠΊΠΈΠΉ ID, Π½Π΅ΠΏΡ€ΠΎΠ·Ρ€Π°Ρ‡Π½Ρ‹ΠΉ ΠΏΠ΅Ρ€ΡΠΎΠ½Π°Π»ΡŒΠ½Ρ‹ΠΉ ΠΊΠΎΠ΄, ref/invite/bonus Π² payload ΠΎΠ±Ρ‹Ρ‡Π½ΠΎ ΡƒΠΊΠ°Π·Ρ‹Π²Π°ΡŽΡ‚ Π½Π° Ρ€Π΅Ρ„Π΅Ρ€Π°Π»ΠΊΡƒ. РаспространСниС Ρ‚Π°ΠΊΠΎΠ³ΠΎ ΠΊΠΎΠ΄Π° Π±Π΅Π· понятной Π½Π΅Ρ€Π΅ΠΊΠ»Π°ΠΌΠ½ΠΎΠΉ Ρ†Π΅Π»ΠΈ являСтся спамом. НС считай start автоматичСски Π±Π΅Π·ΠΎΠ±ΠΈΠ΄Π½Ρ‹ΠΌ тСхничСским ΠΏΠ°Ρ€Π°ΠΌΠ΅Ρ‚Ρ€ΠΎΠΌ. Допускай явно ΠΎΠ±ΡŠΡΡΠ½Ρ‘Π½Π½ΡƒΡŽ ссылку Π½Π° ΠΊΠΎΠ½ΠΊΡ€Π΅Ρ‚Π½ΡƒΡŽ ΡΡ‚Π°Ρ‚ΡŒΡŽ, Π·Π°Π΄Π°Ρ‡Ρƒ, Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚ ΠΈΠ»ΠΈ ΠΏΠΎΠ»Π΅Π·Π½ΡƒΡŽ Ρ„ΡƒΠ½ΠΊΡ†ΠΈΡŽ Π±Π΅Π· привлСчСния/вознаграТдСния, Π° Ρ‚Π°ΠΊΠΆΠ΅ обсуТдСниС синтаксиса deep links. Имя Π±ΠΎΡ‚Π° ΠΈ случайныС символы сами ΠΏΠΎ сСбС Π½Π΅ Π΄ΠΎΠΊΠ°Π·Ρ‹Π²Π°ΡŽΡ‚ Π΅Π³ΠΎ Ρ€Π΅ΠΏΡƒΡ‚Π°Ρ†ΠΈΡŽ. +- Бсылка Π½Π° ΠΏΡ€ΠΎΡ„ΠΈΠ»ΡŒ Π² Instagram, Telegram ΠΈΠ»ΠΈ Π΄Ρ€ΡƒΠ³ΠΎΠΉ сСти Π½Π΅ являСтся Ρ€Π΅Ρ„Π΅Ρ€Π°Π»ΠΊΠΎΠΉ. ID ΡΡ‚Π°Ρ‚ΡŒΠΈ, поста, knowledge base, Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚Π°, Π·Π°Π΄Π°Ρ‡ΠΈ; ΠΎΠ±Ρ‹Ρ‡Π½Ρ‹Π΅ UTM-ΠΌΠ΅Ρ‚ΠΊΠΈ; ссылка-ΠΏΡ€ΠΈΠ³Π»Π°ΡˆΠ΅Π½ΠΈΠ΅ Π² обсуТдСниС Π±Π΅Π· Ρ€Π΅ΠΊΠ»Π°ΠΌΡ‹ Π½Π΅ ΡΠ²Π»ΡΡŽΡ‚ΡΡ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŒΡΠΊΠΈΠΌΠΈ Ρ€Π΅Ρ„Π΅Ρ€Π°Π»ΡŒΠ½Ρ‹ΠΌΠΈ ΠΊΠΎΠ΄Π°ΠΌΠΈ. +- Бсылки Π½Π° источники новостСй, Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚Π°Ρ†ΠΈΡŽ, ΡΡ‚Π°Ρ‚ΡŒΠΈ, исслСдования ΠΈ ΠΌΠ°Ρ‚Π΅Ρ€ΠΈΠ°Π»Ρ‹ ΠΊ обсуТдаСмой ΠΈΠ½Ρ„ΠΎΡ€ΠΌΠ°Ρ†ΠΈΠΈ Ρ€Π°Π·Ρ€Π΅ΡˆΠ΅Π½Ρ‹. Π£ΠΏΠΎΠΌΠΈΠ½Π°Π½ΠΈΠ΅ коммСрчСского ΠΏΡ€ΠΎΠ΄ΡƒΠΊΡ‚Π°, Ρ†Π΅Π½Π°, Π»ΠΈΡ‡Π½Ρ‹ΠΉ ΠΎΠΏΡ‹Ρ‚ ΠΈΠ»ΠΈ рСкомСндация Π² ΠΎΡ‚Π²Π΅Ρ‚ Π½Π° ΠΊΠΎΠ½ΠΊΡ€Π΅Ρ‚Π½Ρ‹ΠΉ вопрос Π±Π΅Π· Ρ€Π΅ΠΊΠ»Π°ΠΌΠ½ΠΎΠ³ΠΎ привлСчСния Ρ€Π°Π·Ρ€Π΅ΡˆΠ΅Π½Ρ‹. ΠŸΡ€ΠΈΠ·Ρ‹Π² ΠΏΡ€ΠΎΡ‡ΠΈΡ‚Π°Ρ‚ΡŒ источник Π½Π΅ Ρ€Π°Π²Π΅Π½ ΠΏΡ€ΠΈΠ·Ρ‹Π²Ρƒ ΠΊΡƒΠΏΠΈΡ‚ΡŒ сСрвис. +- Π¦ΠΈΡ‚Π°Ρ‚Π° Ρ€Π΅ΠΊΠ»Π°ΠΌΡ‹, Ρ€Π°Π·Π±ΠΎΡ€ ΠΌΠΎΡˆΠ΅Π½Π½ΠΈΡ‡Π΅ΡΡ‚Π²Π°, ΠΏΡ€Π΅Π΄ΡƒΠΏΡ€Π΅ΠΆΠ΄Π΅Π½ΠΈΠ΅, ΠΊΡ€ΠΈΡ‚ΠΈΠΊΠ° ΠΈΠ»ΠΈ Π½ΠΎΠ²ΠΎΡΡ‚ΡŒ со спорной ссылкой Π½Π΅ ΡΠ²Π»ΡΡŽΡ‚ΡΡ спамом, Ссли Π°Π²Ρ‚ΠΎΡ€ ΠΈΠΌΠ΅Π½Π½ΠΎ обсуТдаСт ΠΌΠ°Ρ‚Π΅Ρ€ΠΈΠ°Π», Π° Π½Π΅ ΠΏΡ€ΠΈΠ²Π»Π΅ΠΊΠ°Π΅Ρ‚ ΠΊ Π½Π΅ΠΌΡƒ. НС ΠΏΡ€ΠΈΠ½ΠΈΠΌΠ°ΠΉ слова Β«Π½Π΅ Ρ€Π΅ΠΊΠ»Π°ΠΌΠ°Β» Π·Π° ΠΈΡΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΠ΅, Ссли Π΄Π°Π»Π΅Π΅ Π΅ΡΡ‚ΡŒ ΡΠ°ΠΌΠΎΡΡ‚ΠΎΡΡ‚Π΅Π»ΡŒΠ½ΠΎΠ΅ ΠΏΡ€ΠΎΠ΄Π²ΠΈΠΆΠ΅Π½ΠΈΠ΅. НС ΠΏΡ€ΠΈΠ΄ΡƒΠΌΡ‹Π²Π°ΠΉ содСрТимоС сайта, Ρ€Π΅Π΄ΠΈΡ€Π΅ΠΊΡ‚Ρ‹ ΠΈΠ»ΠΈ Ρ€Π΅ΠΏΡƒΡ‚Π°Ρ†ΠΈΡŽ нСизвСстного Π΄ΠΎΠΌΠ΅Π½Π°. + +2. АбстрактноС ΠΏΡ€ΠΈΠ²Π»Π΅Ρ‡Π΅Π½ΠΈΠ΅ ΠΊ Π·Π°Ρ€Π°Π±ΠΎΡ‚ΠΊΡƒ ΠΈΠ»ΠΈ скрытой Ρ€Π°Π±ΠΎΡ‚Π΅: +- ΠŸΡ€ΠΈΠ³Π»Π°ΡˆΠ΅Π½ΠΈΠ΅ ΠΆΠ΅Π»Π°ΡŽΡ‰ΠΈΡ… Π·Π°Ρ€Π°Π±ΠΎΡ‚Π°Ρ‚ΡŒ, Π½Π°ΠΉΡ‚ΠΈ ΠΏΠΎΠ΄Ρ€Π°Π±ΠΎΡ‚ΠΊΡƒ, ΠΏΠΎΠ»ΡƒΡ‡Π°Ρ‚ΡŒ пассивный Π΄ΠΎΡ…ΠΎΠ΄, Π²ΡΡ‚ΡƒΠΏΠΈΡ‚ΡŒ Π² ΠΊΠΎΠΌΠ°Π½Π΄Ρƒ/сотрудничСство Π±Π΅Π· ΠΊΠΎΠ½ΠΊΡ€Π΅Ρ‚Π½ΠΎΠΉ ΠΏΡ€ΠΎΡ„Π΅ΡΡΠΈΠΎΠ½Π°Π»ΡŒΠ½ΠΎΠΉ Ρ„ΡƒΠ½ΠΊΡ†ΠΈΠΈ ΠΈ ΡΠΎΠ΄Π΅Ρ€ΠΆΠ°Ρ‚Π΅Π»ΡŒΠ½Ρ‹Ρ… обязанностСй являСтся спамом. ΠŸΡ€ΠΈΠ·Ρ‹Π² ΠΌΠΎΠΆΠ΅Ρ‚ Π±Ρ‹Ρ‚ΡŒ нСявным: Β«ΠΊΠΎΠΌΡƒ интСрСсно», Β«Π΅ΡΡ‚ΡŒ мСста», Β«Π½ΡƒΠΆΠ½Ρ‹ люди», «подробности Ρƒ мСня»; Π±ΡƒΠΊΠ²Π°Π»ΡŒΠ½ΠΎΠ΅ «пиши Π² Π›Π‘Β» Π½Π΅ΠΎΠ±ΡΠ·Π°Ρ‚Π΅Π»ΡŒΠ½ΠΎ. +- Π Π°Π·ΠΌΠ΅Ρ€ Π΄ΠΎΡ…ΠΎΠ΄Π°, число часов, возраст, Π½Π°Π»ΠΈΡ‡ΠΈΠ΅ Ρ‚Π΅Π»Π΅Ρ„ΠΎΠ½Π° ΠΈ отсутствиС ΠΎΠΏΡ‹Ρ‚Π° Π½Π΅ Ρ€Π°ΡΠΊΡ€Ρ‹Π²Π°ΡŽΡ‚ содСрТаниС Ρ€Π°Π±ΠΎΡ‚Ρ‹. ЀинансовыС схСмы ΠΈ обСщания нСрСалистичной ΠΏΡ€ΠΈΠ±Ρ‹Π»ΠΈ ΠΎΡΡ‚Π°ΡŽΡ‚ΡΡ спамом, Π΄Π°ΠΆΠ΅ рядом с ΠΎΠ±Ρ‹Ρ‡Π½Ρ‹ΠΌ ΠΊΠΎΠΌΠΌΠ΅Π½Ρ‚Π°Ρ€ΠΈΠ΅ΠΌ. +- ΠŸΠΎΠ»Π½ΠΎΡ†Π΅Π½Π½Π°Ρ вакансия с ΠΊΠΎΠ½ΠΊΡ€Π΅Ρ‚Π½ΠΎΠΉ Ρ€ΠΎΠ»ΡŒΡŽ ΠΈΠ»ΠΈ ΠΏΡ€ΠΎΡ„Π΅ΡΡΠΈΠΎΠ½Π°Π»ΡŒΠ½ΠΎΠΉ Ρ„ΡƒΠ½ΠΊΡ†ΠΈΠ΅ΠΉ ΠΈ ΡΠΎΠ΄Π΅Ρ€ΠΆΠ°Ρ‚Π΅Π»ΡŒΠ½Ρ‹ΠΌ описаниСм Π·Π°Π΄Π°Ρ‡, Ρ‚Ρ€Π΅Π±ΠΎΠ²Π°Π½ΠΈΠΉ, условий ΠΈΠ»ΠΈ контСкста Π½Π°ΠΉΠΌΠ° Π½Π΅ являСтся спамом. ΠšΠΎΠ½Ρ‚Π°ΠΊΡ‚ Ρ€Π΅ΠΊΡ€ΡƒΡ‚Π΅Ρ€Π°, username, Ρ‚Π΅Π»Π΅Ρ„ΠΎΠ½, ΠΏΡ€ΠΎΡΡŒΠ±Π° ΠΏΡ€ΠΈΡΠ»Π°Ρ‚ΡŒ ΠΎΡ‚ΠΊΠ»ΠΈΠΊ сами ΠΏΠΎ сСбС Π½Π΅ ΡΠ²Π»ΡΡŽΡ‚ΡΡ ΠΏΡ€ΠΈΠ·Π½Π°ΠΊΠ°ΠΌΠΈ спама. +- Вакансия Π² iGaming, casino ΠΈΠ»ΠΈ sportsbook ΠΊΠΎΠΌΠΏΠ°Π½ΠΈΠΈ Π½Π΅ являСтся Ρ€Π΅ΠΊΠ»Π°ΠΌΠΎΠΉ ΠΈΠ³Ρ€Ρ‹ для ΠΈΠ³Ρ€ΠΎΠΊΠΎΠ². ΠŸΡ€ΠΎΡ„ΠΈΠ»ΡŒ policy_profile=jobs_hr допускаСт вакансии, Ρ€Π΅ΠΊΡ€ΡƒΡ‚ΠΈΠ½Π³, обсуТдСниС ΠΊΠ°Π½Π΄ΠΈΠ΄Π°Ρ‚ΠΎΠ² ΠΈ ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Ρ‹ Ρ€Π΅ΠΊΡ€ΡƒΡ‚Π΅Ρ€ΠΎΠ², Π½ΠΎ Π½Π΅ абстрактный Π·Π°Ρ€Π°Π±ΠΎΡ‚ΠΎΠΊ, финансовыС схСмы ΠΈΠ»ΠΈ Ρ€Π΅Ρ„Π΅Ρ€Π°Π»ΡŒΠ½ΡƒΡŽ Ρ€Π΅ΠΊΠ»Π°ΠΌΡƒ. +- ΠžΠ±ΡΡƒΠΆΠ΄Π΅Π½ΠΈΠ΅ своСй Π·Π°Ρ€ΠΏΠ»Π°Ρ‚Ρ‹, Ρ†Π΅Π½, Π΄ΠΎΡ…ΠΎΠ΄ΠΎΠ², поиск ΠΏΡ€ΠΎΡ„Π΅ΡΡΠΈΠΎΠ½Π°Π»ΡŒΠ½ΠΎΠ³ΠΎ совСта, ΠΏΡ€ΠΎΡΡŒΠ±Ρ‹ ΠΎ ΠΏΠΎΠΌΠΎΡ‰ΠΈ ΠΈ Π±Ρ‹Ρ‚ΠΎΠ²Ρ‹Π΅ прСдлоТСния ΠΏΠΎΠΌΠΎΡ‰ΠΈ/ΠΏΠΎΠ΅Π·Π΄ΠΎΠΊ/ΠΏΠ΅Ρ€Π΅Π΄Π°Ρ‡Π΅ΠΊ Π½Π΅ ΡΠ²Π»ΡΡŽΡ‚ΡΡ ΠΏΡ€ΠΈΠ³Π»Π°ΡˆΠ΅Π½ΠΈΠ΅ΠΌ Π² схСму Π·Π°Ρ€Π°Π±ΠΎΡ‚ΠΊΠ°. БообщСния ΠΎ ΠΏΠΈΡ‚ΠΎΠΌΡ†Π°Ρ… Ρ€Π°Π·Ρ€Π΅ΡˆΠ΅Π½Ρ‹ ΠΏΡ€ΠΈ отсутствии ΡΠ°ΠΌΠΎΡΡ‚ΠΎΡΡ‚Π΅Π»ΡŒΠ½ΠΎΠΉ Ρ€Π΅ΠΊΠ»Π°ΠΌΡ‹/схСмы. + +ΠžΠ±Ρ‰ΠΈΠ΅ Π³Ρ€Π°Π½ΠΈΡ†Ρ‹: - ΠžΠ±Ρ‹Ρ‡Π½Ρ‹Π΅ ΠΊΠΎΡ€ΠΎΡ‚ΠΊΠΈΠ΅ Ρ€Π΅ΠΏΠ»ΠΈΠΊΠΈ, обсуТдСниС Ρ‚Π΅Ρ…Π½ΠΎΠ»ΠΎΠ³ΠΈΠΉ, ΠΌΠΎΠ΄Π΅Π»Π΅ΠΉ, вСрсий, новостСй, ΠΎΠΆΠΈΠ΄Π°Π½ΠΈΠΉ ΠΈ ΠΌΠ½Π΅Π½ΠΈΠΉ сами ΠΏΠΎ сСбС Π½Π΅ ΡΠ²Π»ΡΡŽΡ‚ΡΡ ΠΏΡ€ΠΈΠ·Π½Π°ΠΊΠ°ΠΌΠΈ спама. -- ΠŸΠΎΠ»ΠΈΡ‚ΠΈΡ‡Π΅ΡΠΊΠΈΠ΅ мнСния, Π»ΠΎΠ·ΡƒΠ½Π³ΠΈ, ΠΊΡ€ΠΈΡ‚ΠΈΠΊΠ° стран, ΠΎΡ€Π³Π°Π½ΠΈΠ·Π°Ρ†ΠΈΠΉ ΠΈΠ»ΠΈ ΠΈΠ΄Π΅ΠΎΠ»ΠΎΠ³ΠΈΠΉ, ΡˆΡƒΡ‚ΠΊΠΈ, Π³Ρ€ΡƒΠ±ΠΎΡΡ‚ΡŒ ΠΈ ΡΠΌΠΎΡ†ΠΈΠΎΠ½Π°Π»ΡŒΠ½ΠΎΡΡ‚ΡŒ сами ΠΏΠΎ сСбС Π½Π΅ ΡΠ²Π»ΡΡŽΡ‚ΡΡ ΠΏΡ€ΠΈΠ·Π½Π°ΠΊΠ°ΠΌΠΈ спама. -- ΠšΡ€Π°Ρ‚ΠΊΠΎΡΡ‚ΡŒ, ΡΠΌΠΎΡ†ΠΈΠΎΠ½Π°Π»ΡŒΠ½ΠΎΡΡ‚ΡŒ, названия ΠΌΠΎΠ΄Π΅Π»Π΅ΠΉ, Π½ΠΎΠΌΠ΅Ρ€Π° вСрсий ΠΈ числа сами ΠΏΠΎ сСбС Π½Π΅ ΡΠ²Π»ΡΡŽΡ‚ΡΡ ΠΏΡ€ΠΈΠ·Π½Π°ΠΊΠ°ΠΌΠΈ спама. -- Π£ΠΌΡ‹ΡˆΠ»Π΅Π½Π½Π°Ρ Π·Π°ΠΌΠ΅Π½Π° Π±ΡƒΠΊΠ² ΠΏΠΎΡ…ΠΎΠΆΠΈΠΌΠΈ символами Π΄Ρ€ΡƒΠ³ΠΎΠ³ΠΎ Π°Π»Ρ„Π°Π²ΠΈΡ‚Π° Π±Π΅Π· ΡΠ°ΠΌΠΎΡΡ‚ΠΎΡΡ‚Π΅Π»ΡŒΠ½ΠΎΠ³ΠΎ ΠΏΡ€ΠΈΠ·Π½Π°ΠΊΠ° спама Π½Π΅ Π΄Π΅Π»Π°Π΅Ρ‚ сообщСниС спамом. -- Π­ΠΌΠΎΠ΄Π·ΠΈ сами ΠΏΠΎ сСбС Π½Π΅ ΡΠ²Π»ΡΡŽΡ‚ΡΡ ΠΏΡ€ΠΈΠ·Π½Π°ΠΊΠΎΠΌ спама. -- ΠšΠΎΠ½Ρ‚Π°ΠΊΡ‚ Ρ€Π΅ΠΊΡ€ΡƒΡ‚Π΅Ρ€Π°, Telegram username, Π½ΠΎΠΌΠ΅Ρ€ Ρ‚Π΅Π»Π΅Ρ„ΠΎΠ½Π°, ΠΏΡ€ΠΎΡΡŒΠ±Π° ΠΏΡ€ΠΈΡΠ»Π°Ρ‚ΡŒ ΠΎΡ‚ΠΊΠ»ΠΈΠΊ ΠΈΠ»ΠΈ Π½Π°ΠΏΠΈΡΠ°Ρ‚ΡŒ Π² Π»ΠΈΡ‡Π½Ρ‹Π΅ сообщСния сами ΠΏΠΎ сСбС Π½Π΅ ΡΠ²Π»ΡΡŽΡ‚ΡΡ ΠΏΡ€ΠΈΠ·Π½Π°ΠΊΠ°ΠΌΠΈ спама. -- ΠŸΠΎΠ»Π½ΠΎΡ†Π΅Π½Π½Π°Ρ вакансия с ΠΊΠΎΠ½ΠΊΡ€Π΅Ρ‚Π½ΠΎΠΉ Ρ€ΠΎΠ»ΡŒΡŽ ΠΈΠ»ΠΈ ΠΏΡ€ΠΎΡ„Π΅ΡΡΠΈΠΎΠ½Π°Π»ΡŒΠ½ΠΎΠΉ Ρ„ΡƒΠ½ΠΊΡ†ΠΈΠ΅ΠΉ ΠΈ ΡΠΎΠ΄Π΅Ρ€ΠΆΠ°Ρ‚Π΅Π»ΡŒΠ½Ρ‹ΠΌ описаниСм Π·Π°Π΄Π°Ρ‡, Ρ‚Ρ€Π΅Π±ΠΎΠ²Π°Π½ΠΈΠΉ, условий ΠΈΠ»ΠΈ контСкста Π½Π°ΠΉΠΌΠ° Π½Π΅ являСтся спамом, Π΄Π°ΠΆΠ΅ Ссли содСрТит прямой ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚ Ρ€Π΅ΠΊΡ€ΡƒΡ‚Π΅Ρ€Π°. -- Вакансия Π² iGaming, casino ΠΈΠ»ΠΈ sportsbook ΠΊΠΎΠΌΠΏΠ°Π½ΠΈΠΈ Π½Π΅ являСтся ΠΏΡ€ΠΎΠ΄Π²ΠΈΠΆΠ΅Π½ΠΈΠ΅ΠΌ Π°Π·Π°Ρ€Ρ‚Π½Ρ‹Ρ… ΠΈΠ³Ρ€. ΠŸΡ€ΠΎΠ΄Π²ΠΈΠΆΠ΅Π½ΠΈΠ΅ΠΌ являСтся Ρ€Π΅ΠΊΠ»Π°ΠΌΠ° ΠΈΠ³Ρ€Ρ‹, бонуса, ставки, ΠΊΠ°Π·ΠΈΠ½ΠΎ-ΠΏΡ€ΠΎΠ΄ΡƒΠΊΡ‚Π° ΠΈΠ»ΠΈ Ρ€Π΅Ρ„Π΅Ρ€Π°Π»ΡŒΠ½ΠΎΠΉ ссылки для ΠΈΠ³Ρ€ΠΎΠΊΠΎΠ². -- ΠŸΡ€ΠΎΡ„ΠΈΠ»ΡŒ policy_profile=jobs_hr ΠΎΠ·Π½Π°Ρ‡Π°Π΅Ρ‚, Ρ‡Ρ‚ΠΎ вакансии, Ρ€Π΅ΠΊΡ€ΡƒΡ‚ΠΈΠ½Π³, обсуТдСниС ΠΊΠ°Π½Π΄ΠΈΠ΄Π°Ρ‚ΠΎΠ² ΠΈ ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Ρ‹ Ρ€Π΅ΠΊΡ€ΡƒΡ‚Π΅Ρ€ΠΎΠ² ΡΠΎΠΎΡ‚Π²Π΅Ρ‚ΡΡ‚Π²ΡƒΡŽΡ‚ Ρ‚Π΅ΠΌΠ°Ρ‚ΠΈΠΊΠ΅ Ρ‡Π°Ρ‚Π°. Он Π½Π΅ Ρ€Π°Π·Ρ€Π΅ΡˆΠ°Π΅Ρ‚ абстрактный Π·Π°Ρ€Π°Π±ΠΎΡ‚ΠΎΠΊ, финансовыС схСмы, Ρ€Π΅Ρ„Π΅Ρ€Π°Π»ΡŒΠ½ΡƒΡŽ Ρ€Π΅ΠΊΠ»Π°ΠΌΡƒ ΠΈΠ»ΠΈ скрытыС условия. -- Если Π½Π΅Ρ‚ Π½ΠΈ ΠΎΠ΄Π½ΠΎΠ³ΠΎ ΠΏΡ€ΠΈΠ·Π½Π°ΠΊΠ° спама ΠΈΠ»ΠΈ увСрСнности нСдостаточно, ΡΡ‚Π°Π²ΡŒ 0. -- НаличиС ΠΎΠ±Ρ‹Ρ‡Π½ΠΎΠΉ, политичСской ΠΈΠ»ΠΈ ΠΏΡ€ΠΎΡ„Π΅ΡΡΠΈΠΎΠ½Π°Π»ΡŒΠ½ΠΎΠΉ Ρ„Ρ€Π°Π·Ρ‹ Π½Π΅ отмСняСт ΡΠ°ΠΌΠΎΡΡ‚ΠΎΡΡ‚Π΅Π»ΡŒΠ½Ρ‹Π΅ ΠΏΡ€ΠΈΠ·Π½Π°ΠΊΠΈ спама: абстрактный Π·Π°Ρ€Π°Π±ΠΎΡ‚ΠΎΠΊ Π±Π΅Π· обязанностСй ΠΈ условий, нСрСалистичный Π΄ΠΎΡ…ΠΎΠ΄, финансовая схСма, Ρ€Π΅ΠΊΠ»Π°ΠΌΠ° ΠΊΠ°Π·ΠΈΠ½ΠΎ-ΠΏΡ€ΠΎΠ΄ΡƒΠΊΡ‚Π°, Ρ€Π΅Ρ„Π΅Ρ€Π°Π»ΡŒΠ½Π°Ρ ссылка, дСанонимизация ΠΈΠ»ΠΈ маскировка Ρ‚Π°ΠΊΠΎΠ³ΠΎ содСрТания. +- ΠŸΠΎΠ»ΠΈΡ‚ΠΈΡ‡Π΅ΡΠΊΠΈΠ΅ мнСния, ΠΊΡ€ΠΈΡ‚ΠΈΠΊΠ° стран, ΠΊΠΎΠΌΠΏΠ°Π½ΠΈΠΉ ΠΈΠ»ΠΈ ΠΈΠ΄Π΅ΠΎΠ»ΠΎΠ³ΠΈΠΉ, ΡˆΡƒΡ‚ΠΊΠΈ, Π³Ρ€ΡƒΠ±ΠΎΡΡ‚ΡŒ, ΡΠΌΠΎΡ†ΠΈΠΎΠ½Π°Π»ΡŒΠ½ΠΎΡΡ‚ΡŒ, названия ΠΌΠΎΠ΄Π΅Π»Π΅ΠΉ ΠΈ числа сами ΠΏΠΎ сСбС Π½Π΅ ΡΠ²Π»ΡΡŽΡ‚ΡΡ ΠΏΡ€ΠΈΠ·Π½Π°ΠΊΠ°ΠΌΠΈ спама. Β«ΠŸΠ΅Ρ€Π²Π°Ρ Π΄ΠΎΠ·Π° бСсплатно» Π² обсуТдСнии Ρ‚Π°Ρ€ΠΈΡ„ΠΎΠ² ΠΌΠΎΠΆΠ΅Ρ‚ Π±Ρ‹Ρ‚ΡŒ ΡˆΡƒΡ‚ΠΊΠΎΠΉ, Π° Π½Π΅ Ρ€Π΅ΠΊΠ»Π°ΠΌΠΎΠΉ. +- Π£ΠΌΡ‹ΡˆΠ»Π΅Π½Π½Π°Ρ Π·Π°ΠΌΠ΅Π½Π° Π±ΡƒΠΊΠ² ΠΏΠΎΡ…ΠΎΠΆΠΈΠΌΠΈ символами Π΄Ρ€ΡƒΠ³ΠΎΠ³ΠΎ Π°Π»Ρ„Π°Π²ΠΈΡ‚Π° ΠΏΠΎΠΌΠΎΠ³Π°Π΅Ρ‚ Ρ€Π°ΡΠΏΠΎΠ·Π½Π°Ρ‚ΡŒ ΡΠΊΡ€Ρ‹Ρ‚ΡƒΡŽ Ρ€Π΅ΠΊΠ»Π°ΠΌΡƒ, Π½ΠΎ Π±Π΅Π· ΡΠ°ΠΌΠΎΡΡ‚ΠΎΡΡ‚Π΅Π»ΡŒΠ½ΠΎΠ³ΠΎ ΠΏΡ€ΠΈΠ·Π½Π°ΠΊΠ° спама Π½Π΅ Π΄Π΅Π»Π°Π΅Ρ‚ сообщСниС спамом. Π­ΠΌΠΎΠ΄Π·ΠΈ сами ΠΏΠΎ сСбС Π½Π΅ ΡΠ²Π»ΡΡŽΡ‚ΡΡ ΠΏΡ€ΠΈΠ·Π½Π°ΠΊΠΎΠΌ спама. +- БСзобидная Ρ„Ρ€Π°Π·Π° Π½Π΅ отмСняСт ΡΠ°ΠΌΠΎΡΡ‚ΠΎΡΡ‚Π΅Π»ΡŒΠ½ΡƒΡŽ Ρ€Π΅Ρ„Π΅Ρ€Π°Π»ΠΊΡƒ, Ρ€Π΅ΠΊΠ»Π°ΠΌΡƒ ΠΈΠ»ΠΈ ΠΏΡ€ΠΈΠ³Π»Π°ΡˆΠ΅Π½ΠΈΠ΅ ΠΊ абстрактному Π·Π°Ρ€Π°Π±ΠΎΡ‚ΠΊΡƒ Π² Ρ‚ΠΎΠΌ ΠΆΠ΅ сообщСнии. +- Если Π½Π΅Ρ‚ Π½ΠΈ ΠΎΠ΄Π½ΠΎΠ³ΠΎ ΠΏΡ€ΠΈΠ·Π½Π°ΠΊΠ° спама ΠΈΠ»ΠΈ увСрСнности нСдостаточно, ΡΡ‚Π°Π²ΡŒ 0. Π˜ΡΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΡ Π΄ΠΎΠ»ΠΆΠ½Ρ‹ Π±Ρ‹Ρ‚ΡŒ ΠΏΠΎΠ΄Ρ‚Π²Π΅Ρ€ΠΆΠ΄Π΅Π½Ρ‹ смыслом сообщСния/обсуТдСния, Π° Π½Π΅ Π΄ΠΎΠ³Π°Π΄ΠΊΠ°ΠΌΠΈ. ` -const spamDetectionPrompt = `Π’Ρ‹ ассистСнт для обнаруТСния спама, Π°Π½Π°Π»ΠΈΠ·ΠΈΡ€ΡƒΡŽΡ‰ΠΈΠΉ сообщСния Π½Π° Ρ€Π°Π·Π»ΠΈΡ‡Π½Ρ‹Ρ… языках. ΠžΡ†Π΅Π½ΠΈ входящСС сообщСниС ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ ΠΈ ΠΎΠΏΡ€Π΅Π΄Π΅Π»ΠΈ, являСтся Π»ΠΈ это сообщСниС спамом ΠΈΠ»ΠΈ Π½Π΅Ρ‚. - -ΠŸΡ€ΠΈΠ·Π½Π°ΠΊΠΈ спама: -- ΠŸΡ€Π΅Π΄Π»ΠΎΠΆΠ΅Π½ΠΈΡ Ρ€Π°Π±ΠΎΡ‚Ρ‹/возмоТности Π·Π°Ρ€Π°Π±ΠΎΡ‚Π°Ρ‚ΡŒ, Π½ΠΎ Π±Π΅Π· Π΄Π΅Ρ‚Π°Π»Π΅ΠΉ ΠΎ Ρ€Π°Π±ΠΎΡ‚Π΅ ΠΈ условиях, с ΠΏΡ€ΠΎΡΡŒΠ±ΠΎΠΉ Π½Π°ΠΏΠΈΡΠ°Ρ‚ΡŒ Π² Π»ΠΈΡ‡Π½Ρ‹Π΅ сообщСния. -- АбстрактныС прСдлоТСния Ρ€Π°Π±ΠΎΡ‚Ρ‹/Π·Π°Ρ€Π°Π±ΠΎΡ‚ΠΊΠ°, с ΠΏΡ€ΠΎΡΡŒΠ±ΠΎΠΉ Π½Π°ΠΏΠΈΡΠ°Ρ‚ΡŒ Π² Π»ΠΈΡ‡Π½Ρ‹Π΅ сообщСния Ρ‚Ρ€Π΅Ρ‚ΡŒΠ΅Π³ΠΎ Π»ΠΈΡ†Π° ΠΈΠ»ΠΈ ΠΏΠΎ Π½ΠΎΠΌΠ΅Ρ€Ρƒ Ρ‚Π΅Π»Π΅Ρ„ΠΎΠ½Π°. -- ΠŸΡ€ΠΎΠ΄Π²ΠΈΠΆΠ΅Π½ΠΈΠ΅ Π°Π·Π°Ρ€Ρ‚Π½Ρ‹Ρ… ΠΈΠ³Ρ€/финансовых схСм. -- ΠŸΡ€ΠΎΠ΄Π²ΠΈΠΆΠ΅Π½ΠΈΠ΅ инструмСнтов Π΄Π΅Π°Π½ΠΎΠ½ΠΈΠΌΠΈΠ·Π°Ρ†ΠΈΠΈ ΠΈ "пробивания" Π»ΠΈΡ‡Π½Ρ‹Ρ… Π΄Π°Π½Π½Ρ‹Ρ…, Π²ΠΊΠ»ΡŽΡ‡Π°Ρ ссылки Π½Π° сайты с Ρ‚Π°ΠΊΠΈΠΌΠΈ инструмСнтами. -- Π’Π½Π΅ΡˆΠ½ΠΈΠ΅ ссылки с явными Ρ€Π΅Ρ„Π΅Ρ€Π°Π»ΡŒΠ½Ρ‹ΠΌΠΈ ΠΊΠΎΠ΄Π°ΠΌΠΈ ΠΈ GET ΠΏΠ°Ρ€Π°ΠΌΠ΅Ρ‚Ρ€Π°ΠΌΠΈ Π²Ρ€ΠΎΠ΄Π΅ "?ref=", "/ref", "invite" ΠΈ Ρ‚.ΠΏ. -- Π£ΠΌΡ‹ΡˆΠ»Π΅Π½Π½Π°Ρ маскировка Ρ€Π΅ΠΊΠ»Π°ΠΌΠ½ΠΎΠ³ΠΎ ΠΈΠ»ΠΈ ΠΌΠΎΡˆΠ΅Π½Π½ΠΈΡ‡Π΅ΡΠΊΠΎΠ³ΠΎ сообщСния Π·Π°ΠΌΠ΅Π½ΠΎΠΉ Π±ΡƒΠΊΠ² Π²Π½ΡƒΡ‚Ρ€ΠΈ ΠΎΠ±Ρ‹Ρ‡Π½Ρ‹Ρ… слов Π½Π° ΠΏΠΎΡ…ΠΎΠΆΠΈΠ΅ символы Π΄Ρ€ΡƒΠ³ΠΎΠ³ΠΎ Π°Π»Ρ„Π°Π²ΠΈΡ‚Π° с Ρ†Π΅Π»ΡŒΡŽ ΠΎΠ±Ρ…ΠΎΠ΄Π° Ρ„ΠΈΠ»ΡŒΡ‚Ρ€Π°. - -Π˜ΡΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΡ: -- БообщСния, связанныС с домашними ΠΆΠΈΠ²ΠΎΡ‚Π½Ρ‹ΠΌΠΈ (часто ΠΎ потСрянных ΠΏΠΈΡ‚ΠΎΠΌΡ†Π°Ρ…) -- ΠŸΡ€ΠΎΡΡŒΠ±Ρ‹ ΠΎ ΠΏΠΎΠΌΠΎΡ‰ΠΈ ΠΈ прСдлоТСния ΠΏΠΎΠΌΠΎΡ‰ΠΈ (часто связанныС с поиском ΠΏΡ€ΠΎΠΏΠ°Π²ΡˆΠΈΡ… людСй ΠΈΠ»ΠΈ Π²Π΅Ρ‰Π΅ΠΉ, ΠΏΠΎΠ΄Π²ΠΎΠ΄ΠΎΠΌ людСй ΠΊΡƒΠ΄Π°-Π»ΠΈΠ±ΠΎ) -- Бсылки Π½Π° ΠΎΠ±Ρ‹Ρ‡Π½Ρ‹Π΅ вСбсайты, Π½Π΅ ΡΠ²Π»ΡΡŽΡ‰ΠΈΠ΅ΡΡ Ρ€Π΅Ρ„Π΅Ρ€Π°Π»ΡŒΠ½Ρ‹ΠΌΠΈ ссылками. -- Π Π΅ΠΊΠΎΠΌΠ΅Π½Π΄Π°Ρ†ΠΈΠΈ ΠΏΠΎ услугам, Ρ‚ΠΎΠ²Π°Ρ€Π°ΠΌ, курсам ΠΈ Ρ‚.ΠΏ. - +const spamDetectionPrompt = `Π’Ρ‹ классификатор спама Π² многоязычных Telegram-Ρ‡Π°Ρ‚Π°Ρ… ΠΈ ΠΏΡƒΠ±Π»ΠΈΡ‡Π½Ρ‹Ρ… коммСнтариях. Анализируй Ρ‚ΠΎΠ»ΡŒΠΊΠΎ candidate ΠΏΠΎ ΡΠ»Π΅Π΄ΡƒΡŽΡ‰Π΅ΠΉ Π΅Π΄ΠΈΠ½ΠΎΠΉ ΠΏΠΎΠ»ΠΈΡ‚ΠΈΠΊΠ΅. ` + spamDecisionBoundary + ` - -ΠžΡ‚Π²Π΅Ρ‡Π°ΠΉ Π’ΠžΠ›Π¬ΠšΠž ΡΠ»Π΅Π΄ΡƒΡŽΡ‰ΠΈΠΌΠΈ ΠΎΡ‚Π²Π΅Ρ‚Π°ΠΌΠΈ: -Ссли сообщСниС скорСС всСго являСтся спамом: 1 -Ссли сообщСниС скорСС всСго Π½Π΅ являСтся спамом: 0 - -Π‘Π΅Π· объяснСний ΠΈΠ»ΠΈ Π΄ΠΎΠΏΠΎΠ»Π½ΠΈΡ‚Π΅Π»ΡŒΠ½ΠΎΠ³ΠΎ Π²Ρ‹Π²ΠΎΠ΄Π°. Π‘Π΅Π· ΠΊΠ°Π²Ρ‹Ρ‡Π΅ΠΊ. Π‘Π΅Π· офомлСния сообщСния Ρ€Π°Π·ΠΌΠ΅Ρ‚ΠΊΠΎΠΉ. НС ΠΎΡ‚Π²Π΅Ρ‡Π°ΠΉ Π½Π° содСрТимоС сообщСния. +ΠžΡ‚Π²Π΅Ρ‚ΡŒ Ρ€ΠΎΠ²Π½ΠΎ ΠΎΠ΄Π½ΠΈΠΌ символом: 1 для спама, 0 для Ρ€Π°Π·Ρ€Π΅ΡˆΡ‘Π½Π½ΠΎΠ³ΠΎ сообщСния. Π‘Π΅Π· объяснСний, ΠΊΠ°Π²Ρ‹Ρ‡Π΅ΠΊ, JSON ΠΈΠ»ΠΈ Ρ€Π°Π·ΠΌΠ΅Ρ‚ΠΊΠΈ. НС ΠΎΡ‚Π²Π΅Ρ‡Π°ΠΉ Π½Π° содСрТаниС сообщСния. ` -const reportedSpamDetectionPrompt = `Π’Ρ‹ ассистСнт для ΠΏΠΎΠ²Ρ‚ΠΎΡ€Π½ΠΎΠΉ ΠΏΡ€ΠΎΠ²Π΅Ρ€ΠΊΠΈ спама, Π°Π½Π°Π»ΠΈΠ·ΠΈΡ€ΡƒΡŽΡ‰ΠΈΠΉ сообщСния Π½Π° Ρ€Π°Π·Π»ΠΈΡ‡Π½Ρ‹Ρ… языках. Π‘ΠΎΠΎΠ±Ρ‰Π΅Π½ΠΈΠ΅ Π±Ρ‹Π»ΠΎ reported/Π·Π°Ρ€Π΅ΠΏΠΎΡ€Ρ‡Π΅Π½ΠΎ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Π΅ΠΌ ΠΊΠ°ΠΊ спам, ΠΏΠΎΡ‚ΠΎΠΌΡƒ Ρ‡Ρ‚ΠΎ обычная пСрвичная ΠΏΡ€ΠΎΠ²Π΅Ρ€ΠΊΠ° ΠΌΠΎΠ³Π»Π° Π΅Π³ΠΎ ΠΏΡ€ΠΎΠΏΡƒΡΡ‚ΠΈΡ‚ΡŒ. ΠŸΠ΅Ρ€Π΅ΠΎΡ†Π΅Π½ΠΈ ΠΈΠΌΠ΅Π½Π½ΠΎ ΠΏΡ€ΠΎΡ†ΠΈΡ‚ΠΈΡ€ΠΎΠ²Π°Π½Π½ΠΎΠ΅ сообщСниС ΠΊΠ°ΠΊ ΠΏΠΎΡ‚Π΅Π½Ρ†ΠΈΠ°Π»ΡŒΠ½Ρ‹ΠΉ спам. - -ΠŸΡ€ΠΈΠ·Π½Π°ΠΊΠΈ спама: -- ΠŸΡ€Π΅Π΄Π»ΠΎΠΆΠ΅Π½ΠΈΡ Ρ€Π°Π±ΠΎΡ‚Ρ‹/возмоТности Π·Π°Ρ€Π°Π±ΠΎΡ‚Π°Ρ‚ΡŒ, Π½ΠΎ Π±Π΅Π· Π΄Π΅Ρ‚Π°Π»Π΅ΠΉ ΠΎ Ρ€Π°Π±ΠΎΡ‚Π΅ ΠΈ условиях, с ΠΏΡ€ΠΎΡΡŒΠ±ΠΎΠΉ Π½Π°ΠΏΠΈΡΠ°Ρ‚ΡŒ Π² Π»ΠΈΡ‡Π½Ρ‹Π΅ сообщСния. -- АбстрактныС прСдлоТСния Ρ€Π°Π±ΠΎΡ‚Ρ‹/Π·Π°Ρ€Π°Π±ΠΎΡ‚ΠΊΠ°, с ΠΏΡ€ΠΎΡΡŒΠ±ΠΎΠΉ Π½Π°ΠΏΠΈΡΠ°Ρ‚ΡŒ Π² Π»ΠΈΡ‡Π½Ρ‹Π΅ сообщСния Ρ‚Ρ€Π΅Ρ‚ΡŒΠ΅Π³ΠΎ Π»ΠΈΡ†Π° ΠΈΠ»ΠΈ ΠΏΠΎ Π½ΠΎΠΌΠ΅Ρ€Ρƒ Ρ‚Π΅Π»Π΅Ρ„ΠΎΠ½Π°. -- ΠŸΡ€ΠΎΠ΄Π²ΠΈΠΆΠ΅Π½ΠΈΠ΅ Π°Π·Π°Ρ€Ρ‚Π½Ρ‹Ρ… ΠΈΠ³Ρ€/финансовых схСм. -- ΠŸΡ€ΠΎΠ΄Π²ΠΈΠΆΠ΅Π½ΠΈΠ΅ инструмСнтов Π΄Π΅Π°Π½ΠΎΠ½ΠΈΠΌΠΈΠ·Π°Ρ†ΠΈΠΈ ΠΈ "пробивания" Π»ΠΈΡ‡Π½Ρ‹Ρ… Π΄Π°Π½Π½Ρ‹Ρ…, Π²ΠΊΠ»ΡŽΡ‡Π°Ρ ссылки Π½Π° сайты с Ρ‚Π°ΠΊΠΈΠΌΠΈ инструмСнтами. -- Π’Π½Π΅ΡˆΠ½ΠΈΠ΅ ссылки с явными Ρ€Π΅Ρ„Π΅Ρ€Π°Π»ΡŒΠ½Ρ‹ΠΌΠΈ ΠΊΠΎΠ΄Π°ΠΌΠΈ ΠΈ GET ΠΏΠ°Ρ€Π°ΠΌΠ΅Ρ‚Ρ€Π°ΠΌΠΈ Π²Ρ€ΠΎΠ΄Π΅ "?ref=", "/ref", "invite" ΠΈ Ρ‚.ΠΏ. -- Π£ΠΌΡ‹ΡˆΠ»Π΅Π½Π½Π°Ρ маскировка Ρ€Π΅ΠΊΠ»Π°ΠΌΠ½ΠΎΠ³ΠΎ ΠΈΠ»ΠΈ ΠΌΠΎΡˆΠ΅Π½Π½ΠΈΡ‡Π΅ΡΠΊΠΎΠ³ΠΎ сообщСния Π·Π°ΠΌΠ΅Π½ΠΎΠΉ Π±ΡƒΠΊΠ² Π²Π½ΡƒΡ‚Ρ€ΠΈ ΠΎΠ±Ρ‹Ρ‡Π½Ρ‹Ρ… слов Π½Π° ΠΏΠΎΡ…ΠΎΠΆΠΈΠ΅ символы Π΄Ρ€ΡƒΠ³ΠΎΠ³ΠΎ Π°Π»Ρ„Π°Π²ΠΈΡ‚Π° с Ρ†Π΅Π»ΡŒΡŽ ΠΎΠ±Ρ…ΠΎΠ΄Π° Ρ„ΠΈΠ»ΡŒΡ‚Ρ€Π°. - -Π˜ΡΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΡ: -- БообщСния, связанныС с домашними ΠΆΠΈΠ²ΠΎΡ‚Π½Ρ‹ΠΌΠΈ. -- ΠŸΡ€ΠΎΡΡŒΠ±Ρ‹ ΠΎ ΠΏΠΎΠΌΠΎΡ‰ΠΈ ΠΈ прСдлоТСния ΠΏΠΎΠΌΠΎΡ‰ΠΈ. -- Бсылки Π½Π° ΠΎΠ±Ρ‹Ρ‡Π½Ρ‹Π΅ вСбсайты, Π½Π΅ ΡΠ²Π»ΡΡŽΡ‰ΠΈΠ΅ΡΡ Ρ€Π΅Ρ„Π΅Ρ€Π°Π»ΡŒΠ½Ρ‹ΠΌΠΈ ссылками. -- Π Π΅ΠΊΠΎΠΌΠ΅Π½Π΄Π°Ρ†ΠΈΠΈ ΠΏΠΎ услугам, Ρ‚ΠΎΠ²Π°Ρ€Π°ΠΌ, курсам ΠΈ Ρ‚.ΠΏ. - -` + spamDecisionBoundary + ` - -Π’Π°ΠΊ ΠΊΠ°ΠΊ это повторная ΠΏΡ€ΠΎΠ²Π΅Ρ€ΠΊΠ° ΠΏΠΎ ΠΆΠ°Π»ΠΎΠ±Π΅, Π±ΡƒΠ΄ΡŒ Π²Π½ΠΈΠΌΠ°Ρ‚Π΅Π»Π΅Π½ ΠΊ Π·Π°Π²ΡƒΠ°Π»ΠΈΡ€ΠΎΠ²Π°Π½Π½ΠΎΠΌΡƒ Ρ€Π΅ΠΊΠ»Π°ΠΌΠ½ΠΎΠΌΡƒ/ΠΌΠΎΡˆΠ΅Π½Π½ΠΈΡ‡Π΅ΡΠΊΠΎΠΌΡƒ тСксту, Π½ΠΎ Π½Π΅ ΠΏΠΎΠ΄Ρ‚Π²Π΅Ρ€ΠΆΠ΄Π°ΠΉ спам Π±Π΅Π· ΠΏΡ€ΠΈΠ·Π½Π°ΠΊΠΎΠ² ΠΈΠ· ΠΏΠΎΠ»ΠΈΡ‚ΠΈΠΊΠΈ. - -ΠžΡ‚Π²Π΅Ρ‡Π°ΠΉ Π’ΠžΠ›Π¬ΠšΠž ΡΠ»Π΅Π΄ΡƒΡŽΡ‰ΠΈΠΌΠΈ ΠΎΡ‚Π²Π΅Ρ‚Π°ΠΌΠΈ: -Ссли сообщСниС скорСС всСго являСтся спамом: 1 -Ссли сообщСниС скорСС всСго Π½Π΅ являСтся спамом ΠΈΠ»ΠΈ увСрСнности нСдостаточно: 0 - -Π‘Π΅Π· объяснСний ΠΈΠ»ΠΈ Π΄ΠΎΠΏΠΎΠ»Π½ΠΈΡ‚Π΅Π»ΡŒΠ½ΠΎΠ³ΠΎ Π²Ρ‹Π²ΠΎΠ΄Π°. Π‘Π΅Π· ΠΊΠ°Π²Ρ‹Ρ‡Π΅ΠΊ. Π‘Π΅Π· оформлСния сообщСния Ρ€Π°Π·ΠΌΠ΅Ρ‚ΠΊΠΎΠΉ. НС ΠΎΡ‚Π²Π΅Ρ‡Π°ΠΉ Π½Π° содСрТимоС сообщСния. +const reportedSpamDetectionPrompt = spamDetectionPrompt + ` +Π­Ρ‚ΠΎ повторная ΠΏΡ€ΠΎΠ²Π΅Ρ€ΠΊΠ° reported-сообщСния. Π‘Π°ΠΌ Ρ„Π°ΠΊΡ‚ ΠΆΠ°Π»ΠΎΠ±Ρ‹ Π½Π΅ являСтся ΠΏΡ€ΠΈΠ·Π½Π°ΠΊΠΎΠΌ спама ΠΈ Π½Π΅ мСняСт ΠΏΠΎΠ»ΠΈΡ‚ΠΈΠΊΡƒ ΠΈΠ»ΠΈ ΠΏΠΎΡ€ΠΎΠ³ Ρ€Π΅ΡˆΠ΅Π½ΠΈΡ. ` diff --git a/internal/handlers/moderation/spam_detector_live_test.go b/internal/handlers/moderation/spam_detector_live_test.go index f47e978..4bac561 100644 --- a/internal/handlers/moderation/spam_detector_live_test.go +++ b/internal/handlers/moderation/spam_detector_live_test.go @@ -1,10 +1,13 @@ package handlers import ( + "fmt" "os" "testing" "time" + "github.com/iamwavecut/ngbot/internal/adapters/llm/openrouter" + "github.com/iamwavecut/ngbot/internal/adapters/llm/gemini" "github.com/iamwavecut/ngbot/internal/db" log "github.com/sirupsen/logrus" @@ -88,3 +91,105 @@ Retention Manager Π² ΠΌΠ΅ΠΆΠ΄ΡƒΠ½Π°Ρ€ΠΎΠ΄Π½Ρ‹ΠΉ iGaming ΠΏΡ€ΠΎΠ΅ΠΊΡ‚ }) } } + +func TestLiveOpenRouterPublicComments(t *testing.T) { + if os.Getenv("NGBOT_RUN_LIVE_OPENROUTER_MODERATION") != "1" { + t.Skip("set NGBOT_RUN_LIVE_OPENROUTER_MODERATION=1 to run the live semantic check") + } + logger := log.New() + logger.SetFormatter(&log.JSONFormatter{}) + provider, err := openrouter.NewOpenRouter(os.Getenv("NG_LLM_OPENROUTER_API_KEY"), "", log.NewEntry(logger)) + if err != nil { + t.Fatal(err) + } + defer func() { _ = provider.(*openrouter.API).Close() }() + detector := NewSpamDetector(provider, log.NewEntry(logger), 45*time.Second) + const newsPost = "Новости" + cases := []struct { + name, message, post string + spam bool + }{ + {"company criticism", "Anthropic ΠΎΠΏΡΡ‚ΡŒ Π±Π°Π½ΠΈΡ‚ ΠΎΠ±Ρ‹Ρ‡Π½Ρ‹Ρ… ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Π΅ΠΉ. OpenAI Ρ‚Π°ΠΊ Π½Π΅ Π΄Π΅Π»Π°Π΅Ρ‚.", "Новости ΠΎ Π±Π»ΠΎΠΊΠΈΡ€ΠΎΠ²ΠΊΠ°Ρ… Anthropic", false}, + {"ban complaint", "МнС Ρ‚ΠΎΠΆΠ΅ ΠΏΡ€ΠΈΠ»Π΅Ρ‚Π΅Π» Π±Π°Π½, просто запустил Π°Π³Π΅Π½Ρ‚ Π½Π° большом ΠΏΡ€ΠΎΠ΅ΠΊΡ‚Π΅.", "Π‘Π»ΠΎΠΊΠΈΡ€ΠΎΠ²ΠΊΠΈ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Π΅ΠΉ Claude", false}, + {"free sample joke", "ΠŸΠ΅Ρ€Π²Π°Ρ Π΄ΠΎΠ·Π° бСсплатно Π·Π°ΠΊΠΎΠ½Ρ‡ΠΈΠ»Π°ΡΡŒ.", "Gemini повысил Ρ†Π΅Π½Ρ‹", false}, + {"news source", "Π’ΠΎΡ‚ источник новости: https://example.org/news/123456?utm_source=telegram", "Новости Ρ‚Π΅Ρ…Π½ΠΎΠ»ΠΎΠ³ΠΈΠΉ", false}, + {"knowledge base ref", "ΠžΡ‚Π²Π΅Ρ‚ Π² Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚Π°Ρ†ΠΈΠΈ: https://docs.example.org/kb/ref/12345", "ΠžΠ±ΡΡƒΠΆΠ΄Π΅Π½ΠΈΠ΅ API", false}, + {"ordinary profile", "Π­Ρ‚ΠΎ Π°Π²Ρ‚ΠΎΡ€ ΡΡ‚Π°Ρ‚ΡŒΠΈ: https://t.me/researcher", "ΠžΠ±ΡΡƒΠΆΠ΄Π΅Π½ΠΈΠ΅ исслСдования", false}, + {"instagram profile", "Автор Π²ΠΈΠ΄Π΅ΠΎ https://instagram.com/researcher", "ΠžΠ±ΡΡƒΠΆΠ΄Π΅Π½ΠΈΠ΅ Π²ΠΈΠ΄Π΅ΠΎ", false}, + {"technical start docs", "ΠŸΡ€ΠΈΠΌΠ΅Ρ€ deep link ΠΈΠ· Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚Π°Ρ†ΠΈΠΈ: https://t.me/sample_bot?start=123456. Π—Π΄Π΅ΡΡŒ start ΠΏΠ΅Ρ€Π΅Π΄Π°Ρ‘Ρ‚ Π°Ρ€Π³ΡƒΠΌΠ΅Π½Ρ‚ ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚Ρ‡ΠΈΠΊΡƒ.", "Как Ρ€Π°Π±ΠΎΡ‚Π°ΡŽΡ‚ deep links Telegram", false}, + {"document bot start", "ΠžΡ‚Ρ‡Ρ‘Ρ‚, ΠΊΠΎΡ‚ΠΎΡ€Ρ‹ΠΉ Ρ‚Ρ‹ просил, открываСтся здСсь: https://t.me/library_bot?start=article_145. Π­Ρ‚ΠΎ конкрСтная ΡΡ‚Π°Ρ‚ΡŒΡ с Ρ€Π΅Π·ΡƒΠ»ΡŒΡ‚Π°Ρ‚Π°ΠΌΠΈ экспСримСнта.", "Π“Π΄Π΅ Ρ€Π΅Π·ΡƒΠ»ΡŒΡ‚Π°Ρ‚Ρ‹ экспСримСнта?", false}, + {"scam warning", "НС ΠΏΠ΅Ρ€Π΅Ρ…ΠΎΠ΄ΠΈΡ‚Π΅ ΠΏΠΎ этой ΠΌΠΎΡˆΠ΅Π½Π½ΠΈΡ‡Π΅ΡΠΊΠΎΠΉ Ρ€Π΅Ρ„Π΅Ρ€Π°Π»ΠΊΠ΅: https://fake.example/register?ref=123456", "Π Π°Π·Π±ΠΎΡ€ ΠΌΠΎΡˆΠ΅Π½Π½ΠΈΡ‡Π΅ΡΡ‚Π²Π°", false}, + {"quoted spam", "Π’ΠΎΡ‚ ΠΊΠ°ΠΊ выглядит спам: Β«Π—Π°Ρ€Π°Π±ΠΎΡ‚ΠΎΠΊ 500 Π΄ΠΎΠ»Π»Π°Ρ€ΠΎΠ² Π² дСнь, ΠΏΠΈΡˆΠΈΡ‚Π΅ Π² Π»ΠΈΡ‡ΠΊΡƒΒ». Как Ρ€Π°ΡΠΏΠΎΠ·Π½Π°Ρ‚ΡŒ Ρ‚Π°ΠΊΠΈΠ΅ схСмы?", "ΠžΠ±ΡΡƒΠΆΠ΄Π΅Π½ΠΈΠ΅ спама", false}, + {"useful recommendation", "Для этой Π·Π°Π΄Π°Ρ‡ΠΈ ΠΏΠΎΠΏΡ€ΠΎΠ±ΡƒΠΉ поиск Π² https://docs.example.org, Ρ‚Π°ΠΌ описано API.", "Π“Π΄Π΅ докумСнтация?", false}, + {"personal subscription experience", "ΠŸΠΎΠ»ΡŒΠ·ΡƒΡŽΡΡŒ ΠΏΠ»Π°Ρ‚Π½ΠΎΠΉ подпиской ΡƒΠΆΠ΅ мСсяц, Ρ†Π΅Π½Π° 20 Π΄ΠΎΠ»Π»Π°Ρ€ΠΎΠ².", "Π‘Ρ€Π°Π²Π½Π΅Π½ΠΈΠ΅ Ρ‚Π°Ρ€ΠΈΡ„ΠΎΠ² ΠΌΠΎΠ΄Π΅Π»Π΅ΠΉ", false}, + {"noncommercial contact", "Напиши ΠΌΠ½Π΅ Π² Π»ΠΈΡ‡ΠΊΡƒ, ΠΏΡ€ΠΈΡˆΠ»ΡŽ настройки для этой ΠΌΠΎΠ΄Π΅Π»ΠΈ.", "Настройки Π»ΠΎΠΊΠ°Π»ΡŒΠ½Ρ‹Ρ… ΠΌΠΎΠ΄Π΅Π»Π΅ΠΉ", false}, + {"detailed hiring", "Π˜Ρ‰Π΅ΠΌ Go-Ρ€Π°Π·Ρ€Π°Π±ΠΎΡ‚Ρ‡ΠΈΠΊΠ°: ΠΏΠΈΡΠ°Ρ‚ΡŒ HTTP API, SQL-запросы ΠΈ тСсты. ВрСбуСтся 3 Π³ΠΎΠ΄Π° ΠΎΠΏΡ‹Ρ‚Π° Go. Π£Π΄Π°Π»Ρ‘Π½Π½ΠΎ, Π·Π°Ρ€ΠΏΠ»Π°Ρ‚Π° 3000 EUR. Π Π΅ΠΊΡ€ΡƒΡ‚Π΅Ρ€ @recruiter.", "Вакансии", false}, + {"igaming hiring", "Retention Manager Π² iGaming: сСгмСнтация CRM, email-ΠΊΠ°ΠΌΠΏΠ°Π½ΠΈΠΈ, A/B-тСсты. НуТСн ΠΎΠΏΡ‹Ρ‚ CRM ΠΎΡ‚ 2 Π»Π΅Ρ‚. ΠšΠΎΠ½Ρ‚Π°ΠΊΡ‚ @recruiter.", "Вакансии", false}, + {"income without explicit dm", "Π•ΡΡ‚ΡŒ мСста для ΠΆΠ΅Π»Π°ΡŽΡ‰ΠΈΡ… Π΄ΠΎΠΏΠΎΠ»Π½ΠΈΡ‚Π΅Π»ΡŒΠ½ΠΎ Π·Π°Ρ€Π°Π±Π°Ρ‚Ρ‹Π²Π°Ρ‚ΡŒ. ΠžΡ‚ 500 Π΄ΠΎΠ»Π»Π°Ρ€ΠΎΠ² Π² дСнь, всСго 2 часа с Ρ‚Π΅Π»Π΅Ρ„ΠΎΠ½ΠΎΠΌ. ΠžΠΏΡ‹Ρ‚ Π½Π΅ Π½ΡƒΠΆΠ΅Π½.", "Новости ΠΌΠΎΠ΄Π΅Π»Π΅ΠΉ", true}, + {"vague recruiting", "НуТны люди для ΡƒΠ΄Π°Π»Ρ‘Π½Π½ΠΎΠΉ занятости, подробности Ρƒ мСня.", newsPost, true}, + {"referral on unknown domain", "https://unknown.example/subscribe?ref=908716", newsPost, true}, + {"referral in path", "Π—Π°Π±ΠΈΡ€Π°ΠΉΡ‚Π΅ бонус https://unknown.example/invite/user908716", newsPost, true}, + {"opaque bot start", "https://t.me/rewards_bot?start=908716", newsPost, true}, + {"bot startapp", "https://t.me/rewards_bot/app?startapp=ref_908716", newsPost, true}, + {"promotional public channel", "ΠŸΠΎΠ΄ΠΏΠΈΡΡ‹Π²Π°ΠΉΡ‚Π΅ΡΡŒ Π½Π° ΠΌΠΎΠΉ ΠΊΠ°Π½Π°Π», всС сСкрСты Ρ‚Π°ΠΌ: https://t.me/promo_channel", newsPost, true}, + {"promotional bot", "ΠŸΠΎΠΏΡ€ΠΎΠ±ΡƒΠΉΡ‚Π΅ нашСго бСсплатного Π±ΠΎΡ‚Π° прямо сСйчас: https://t.me/promo_bot", newsPost, true}, + {"bare casino link", "https://casino.example/signup", newsPost, true}, + {"bare crypto exchange", "https://crypto-exchange.example/", newsPost, true}, + {"casino advertisement", "ЗарСгистрируйся Π² ΠΊΠ°Π·ΠΈΠ½ΠΎ ΠΈ Π·Π°Π±Π΅Ρ€ΠΈ бонус: https://casino.example/signup", newsPost, true}, + {"commercial signup", "ΠžΡ„ΠΎΡ€ΠΌΠ»ΡΠΉΡ‚Π΅ подписку Π½Π° AI-сСрвис со скидкой: https://service.example/subscribe?ic=908716", "Новости ΠΌΠΎΠ΄Π΅Π»Π΅ΠΉ", true}, + {"deanonymization advertisement", "Π Π΅ΠΊΠΎΠΌΠ΅Π½Π΄ΡƒΡŽ сСрвис пробивания Ρ‡ΡƒΠΆΠΈΡ… Ρ‚Π΅Π»Π΅Ρ„ΠΎΠ½ΠΎΠ² ΠΈ Π»ΠΈΡ‡Π½Ρ‹Ρ… Π΄Π°Π½Π½Ρ‹Ρ…, подробности Ρƒ @seller.", newsPost, true}, + {"safe prefix with income", "Π–Π΄Ρƒ Π½ΠΎΠ²ΡƒΡŽ модСль. А ΠΏΠΎΠΊΠ° Π½ΡƒΠΆΠ½Ρ‹ люди для Π΄ΠΎΡ…ΠΎΠ΄Π° 1000 Π΄ΠΎΠ»Π»Π°Ρ€ΠΎΠ² Π² нСдСлю, подробности Π² Π›Π‘.", "МодСли", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + result, err := detector.IsSpam(t.Context(), tc.message, ClassificationContext{Conversation: []ConversationMessage{{Role: "original_post", Message: tc.post}}}) + if err != nil { + t.Fatal(err) + } + if result == nil || *result != tc.spam { + t.Fatalf("classification=%v want=%t", result, tc.spam) + } + }) + } + for _, profile := range []string{db.LLMModerationProfileGeneral, db.LLMModerationProfileJobsHR} { + for _, index := range []int{14, 15, 16} { + tc := cases[index] + t.Run(profile+"/"+tc.name, func(t *testing.T) { + result, err := detector.IsSpam(t.Context(), tc.message, ClassificationContext{Profile: profile}) + if err != nil || result == nil || *result != tc.spam { + t.Fatalf("classification=%v error=%v want=%t", result, err, tc.spam) + } + }) + } + } + for i := range 3 { + t.Run(fmt.Sprintf("cache reuse %d", i), func(t *testing.T) { + result, err := detector.IsSpam(t.Context(), "Бпасибо, Ρ‚Π΅ΠΏΠ΅Ρ€ΡŒ понятно!", ClassificationContext{}) + if err != nil || result == nil || *result { + t.Fatalf("classification=%v error=%v", result, err) + } + }) + } +} + +func TestLiveOpenRouterExistingExamples(t *testing.T) { + if os.Getenv("NGBOT_RUN_LIVE_OPENROUTER_MODERATION") != "1" { + t.Skip("live semantic check is disabled") + } + logger := log.New() + logger.SetFormatter(&log.JSONFormatter{}) + provider, err := openrouter.NewOpenRouter(os.Getenv("NG_LLM_OPENROUTER_API_KEY"), "", log.NewEntry(logger)) + if err != nil { + t.Fatal(err) + } + defer func() { _ = provider.(*openrouter.API).Close() }() + detector := NewSpamDetector(provider, log.NewEntry(logger), 45*time.Second) + for index, example := range examples { + t.Run(fmt.Sprintf("existing example %d", index), func(t *testing.T) { + result, err := detector.IsSpam(t.Context(), example.Message, ClassificationContext{}) + if err != nil || result == nil || *result != (example.Response == 1) { + t.Fatalf("classification=%v error=%v want=%d", result, err, example.Response) + } + }) + } +} diff --git a/internal/handlers/moderation/spam_detector_test.go b/internal/handlers/moderation/spam_detector_test.go index 4b58889..0e3287b 100644 --- a/internal/handlers/moderation/spam_detector_test.go +++ b/internal/handlers/moderation/spam_detector_test.go @@ -183,6 +183,10 @@ func TestSpamDetectorPromptsRequireExplicitSpamEvidence(t *testing.T) { "ΡΡ‚Π°Π²ΡŒ 1 Ρ‚ΠΎΠ»ΡŒΠΊΠΎ", "Π½Π΅ ΡΠ²Π»ΡΡŽΡ‚ΡΡ голосованиСм", "количСство ΠΏΡ€ΠΈΠΌΠ΅Ρ€ΠΎΠ² класса 1", + "startapp", + "knowledge base", + "подписчик ΠΊΠ°Π½Π°Π»Π°", + "Π±ΡƒΠΊΠ²Π°Π»ΡŒΠ½ΠΎΠ΅", "политичСскиС мнСния", "обсуТдСниС Ρ‚Π΅Ρ…Π½ΠΎΠ»ΠΎΠ³ΠΈΠΉ", "названия ΠΌΠΎΠ΄Π΅Π»Π΅ΠΉ", diff --git a/scripts/openrouter-stats.py b/scripts/openrouter-stats.py new file mode 100755 index 0000000..4016de5 --- /dev/null +++ b/scripts/openrouter-stats.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +import json +import math +import re +import statistics +import sys +from collections import Counter +from decimal import Decimal + +ansi = re.compile(r"\x1b\[[0-9;]*m") +decoder = json.JSONDecoder(parse_float=Decimal) +records = {} +failures = 0 +for raw in sys.stdin: + try: + envelope = json.loads(raw) + line = envelope.get("log", raw) if isinstance(envelope, dict) else raw + except (ValueError, TypeError): + line = raw + line = ansi.sub("", line) + if 'msg="OpenRouter request failed"' in line or '"msg":"OpenRouter request failed"' in line: + failures += 1 + if "OpenRouter usage metadata" not in line and "OpenRouter generation metadata" not in line: + continue + try: + entry = json.loads(line, parse_float=Decimal) + except ValueError: + entry = {} + for key in ("generation_id", "usage", "generation", "duration_ms", "finish_reason", "served_provider"): + match = re.search(r"(?:^|\s)" + key + r"=", line) + if match: + try: + entry[key], _ = decoder.raw_decode(line[match.end():]) + except ValueError: + pass + generation_id = entry.get("generation_id") + if generation_id: + if "OpenRouter usage metadata" in line: + entry["_completion_seen"] = True + if entry.get("generation") is None: + entry.pop("generation", None) + records.setdefault(generation_id, {}).update(entry) + +records = {key: value for key, value in records.items() if value.get("_completion_seen")} +cache_requests = cache_hits = cache_prompt = cached = prompt = completion = reasoning = writes = 0 +cost = Decimal(0) +cost_requests = usage_requests = generation_requests = 0 +providers = Counter() +finishes = Counter() +durations = [] +cache_discounts = Decimal(0) +for entry in records.values(): + usage = entry.get("usage") or {} + generation = entry.get("generation") or {} + generation_requests += bool(generation) + providers[generation.get("provider_name") or entry.get("served_provider") or "unknown"] += 1 + finishes[entry.get("finish_reason", "unknown")] += 1 + if usage: + usage_requests += 1 + count = usage.get("prompt_tokens", 0) + prompt += count + completion += usage.get("completion_tokens", 0) + reasoning += (usage.get("completion_tokens_details") or {}).get("reasoning_tokens", 0) + details = usage.get("prompt_tokens_details") or {} + if details.get("cached_tokens") is not None: + cache_requests += 1 + cache_prompt += count + hit = details["cached_tokens"] + cached += hit + cache_hits += hit > 0 + writes += details.get("cache_write_tokens", 0) or 0 + charge = usage.get("cost") + if charge is None: + charge = generation.get("total_cost") + if charge is not None: + cost_requests += 1 + cost += Decimal(str(charge)) + if generation.get("cache_discount") is not None: + cache_discounts += Decimal(str(generation["cache_discount"])) + if entry.get("duration_ms") is not None: + durations.append(float(entry["duration_ms"])) + +def fraction(numerator, denominator): + return numerator / denominator if denominator else None + +def percentile(values, percent): + return sorted(values)[max(0, math.ceil(percent * len(values)) - 1)] if values else None + +json.dump({ + "completed_requests": len(records), "failed_requests": failures, + "usage_observed_requests": usage_requests, "generation_observed_requests": generation_requests, + "providers": dict(providers), "finish_reasons": dict(finishes), + "prompt_tokens": prompt, "completion_tokens": completion, "reasoning_tokens": reasoning, + "cached_tokens": cached, "cache_write_tokens": writes, + "cache_observed_requests": cache_requests, "cache_hit_requests": cache_hits, + "cache_hit_request_rate": fraction(cache_hits, cache_requests), + "cached_input_token_rate": fraction(cached, cache_prompt), + "cost_observed_requests": cost_requests, "cost_usd": str(cost), + "cost_per_request_usd": str(cost / cost_requests) if cost_requests else None, + "reported_cache_discount_usd": str(cache_discounts), + "duration_mean_ms": statistics.mean(durations) if durations else None, + "duration_p50_ms": percentile(durations, .5), "duration_p95_ms": percentile(durations, .95), +}, sys.stdout, indent=2) +sys.stdout.write("\n")